Compare commits

...
46 Commits
Author SHA1 Message Date
github-actions[bot] ae46562308 Bump versions for v10.83 2026-08-11 16:23:54 +00:00
Lauri Ojansivu f48b2c03e2 Prepare v10.83 release 2026-08-11 19:22:45 +03:00
Lauri Ojansivu 3f9b81bcbc CHANGELOG: the public-board half of the avatar fix. 2026-08-11 19:21:54 +03:00
Lauri Ojansivu 484eae7362 Avatars: honour the ?boardId= the client has been sending all along.
Follow-up to the previous commit, found while checking why the Admin Panel
showed a picture that a board did not. The two URLs differ in one thing: the
avatarUrl helper in client/components/users/userAvatar.js appends
`?boardId=<id>`, and says why in its own comment - "so public viewers can access
avatars on public boards". The Admin Panel uses profile.avatarUrl raw.

/cdn/storage/avatars/:fileName never read that parameter. It required a signed-in
user and nothing else, so on a public board every visitor who was not logged in
got 401 and the missing-picture icon, which is the case the parameter was added
for. Fixing Meteor.userId() alone would have left that half broken.

avatarIsOnAPublicBoard() answers it: the named board must exist, be public, AND
have the avatar's owner as a member. The last part is not ceremony - without it,
naming any public board would unlock any avatar on the instance, and a public
board publishes its own members, not everybody.

The legacy redirect now keeps the query string. /cfs/files/avatars/<id> 301s to
/cdn/storage/avatars/<id>, and that is the path EVERY migrated 6.x avatar URL
takes, so dropping ?boardId= there would 401 exactly the installs the previous
commit set out to fix.

tests/requestUserAuth.test.cjs gains both: that the public path checks all three
conditions and is awaited (an unawaited Promise is truthy, which would make every
avatar public), and that the redirect carries the query and only when there is
one.

Thanks to markusst1982 and xet7 !

Fixes #6583,
2026-08-11 19:21:19 +03:00
Lauri Ojansivu 3f80161607 CHANGELOG: the avatar route that could never serve anybody. 2026-08-11 19:21:19 +03:00
Lauri Ojansivu bf047d53c8 Avatars: ask the request who it is, because Meteor.userId() cannot.
Following the same upgrade as #6583, profile pictures came back as broken
images:

  "I can see Profile Pics from the user in the Admin/User Pannel if i open a
   User. But on the boards i see only the 'missing Picture' Icon"
  "initials are OK, but there is broken avatar image for those that have image"

Nothing was lost, and the migration is not at fault. The avatar files migrate,
and the Meteor-Files record made from a CollectionFS filerecord even reuses its
_id (cfsRecordToMeteorFile), so a 6.x URL still names the right object. What
broke is the request for it.

A 6.x install stores profile.avatarUrl as /cfs/files/avatars/<id>. That route
serves the legacy CollectionFS bytes if they are still there, and otherwise
redirects to /cdn/storage/avatars/<id> - which asked who was asking like this:

  const userId = Meteor.userId();
  if (!userId) { res.writeHead(401); ... }

Meteor.userId() reads the current DDP invocation's environment. That exists
inside a method or a publication and NOT in a WebApp handler, where it does not
return "nobody" - it THROWS "Meteor.userId can only be invoked in method calls
or publications". The handler wraps its body in a try/catch that answers 500, so
the throw was swallowed into a broken image and no avatar served through that
route ever reached anybody, on any install. The upgrade did not cause it; it
moved every avatar URL onto the route where it already applied.

server/routes/legacyAttachments.js had the identical call, so legacy attachment
URLs failed the same way and for the same reason.

Initials were unaffected because they are rendered client-side from the username
and fetch nothing, which is why the report describes them as fine.

The other half of the report - the Admin Panel showing a picture that a board did
not - is NOT explained by this. Both templates end at the same route:
peopleBody.jade puts profile.avatarUrl into an img src, and a board goes through
+userAvatar, which appends ?boardId=. That parameter is the difference, and it
has a cause of its own; the commit that follows this one deals with it.

An HTTP request carries its identity in the request: a bearer token, an
X-Auth-Token header, an ?authToken= parameter, or the login cookie - and on
Sandstorm, a platform-injected user id and no Meteor token at all.
server/routes/universalFileServer.js has always resolved it that way and serves
attachments correctly today. server/lib/requestUser.js lifts that resolution out
so the two routes that were guessing share it rather than grow a third copy, and
so the next file route has something to call. It never throws: a caller deciding
whether to serve a file wants an answer, not an exception its own catch will turn
back into a 500.

universalFileServer keeps its own inline copy for now. Rewiring a working file
server is a separate change from fixing a broken one, and this commit should be
readable as the fix.

tests/requestUserAuth.test.cjs pins that neither route calls Meteor.userId(),
that both await the resolver (an unawaited Promise is truthy and would authorise
everybody), that all four token carriers and the Sandstorm path are handled, and
that the migration still reuses the id the old URL names.

Verified by reading and by the source guard; confirming the served image needs an
upgraded instance, which this sandbox has no Docker to run.

Thanks to markusst1982 and xet7 !

Fixes #6583,
2026-08-11 19:21:19 +03:00
Lauri Ojansivu c623718db1 CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.

Thanks to xet7 !
2026-08-11 19:02:58 +03:00
Lauri Ojansivu 4b89394087 CHANGELOG: PassBleed is the Upcoming release, not part of v10.82.
v10.82 was prepared and released while this was being written, so the PassBleed
entry had landed in a section for a release that had already gone out without the
fix - the same way the chart-index entry did after v10.81.

It moves to a new Upcoming section with its own summary and binaries table, and
v10.82 goes back to naming WhereBleed as its single CRITICAL issue, which is what
it actually shipped.

Thanks to TWPaMWang and xet7 !
2026-08-11 19:02:58 +03:00
Lauri Ojansivu 5269d0ba5c PassBleed: the Excel card export authorised one board and read a card from another.
GHSA-6p5m-f9p2-wqm5, CWE-639, CVSS 6.5.

    GET /api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel

checked whether the caller could see the board in :boardId, then resolved the card
by :cardId alone. Nothing confirmed the card was on that board, so the two
identifiers came apart: one decided the authorisation, the other decided the data.
canExport() is given this._boardId and never sees this._cardId, so it can answer
"may this user see that board?" and cannot answer "may this user see that card?".

The pass is self-service. POST /api/boards takes `permission` straight from the
request body, so any authenticated user could mint their own PUBLIC board, name it
as :boardId, and pass the id of a card in somebody else's private board as
:cardId. :listId was never used in a query at all and could be any string.

What came back was the card: title, full description, members and assignees, every
comment with its author, checklists and checklist items, subtask titles,
attachment metadata - and, because image attachments are read through
getReadStream() and embedded with workbook.addImage, the attachment BYTES, which
is functionally an arbitrary private attachment download. One board could be
reused while :cardId was substituted, making it a scriptable bulk-collection
primitive rather than a single disclosure, and the REST API is on by default in
the shipped Docker configuration.

THE FIX WAS ALREADY IN THE CODEBASE, ONE FILE AWAY. The structurally identical
PDF route has always resolved getCard({ _id, boardId, listId }) and 404s a
cross-board id (ExporterCardPDF._getCardData). That control is what shows the
Excel exporter's omission was a defect rather than a decision, and it is exactly
what the Excel exporter does now.

Constraining the QUERY matters more than adding a check after it. The exporter
fans out on the same card id - checklists, checklist items, subtasks, comments,
attachments - and none of those carry a board constraint of their own. A card that
cannot resolve outside the authorised board makes every one of them safe by
construction rather than by a second guard that could be missed the same way.

The route binds the two identifiers as well, before either branch builds. That is
deliberate duplication: it is cheapest to state where both identifiers arrive
together, and it covers the public-board branch, which skips authentication
entirely and would otherwise rely on the exporter alone. A card that is not on the
named board is a 404 rather than a 403, so the difference does not tell an
unauthorised caller whether a given card id exists.

Audited the reporter's systemic point: the only two single-card export routes are
this one and the PDF one, and the attachment handlers already check
card.boardId !== boardId. tests/exportExcelCardContainment.test.cjs pins the
constrained lookup in both places, that the route checks before EITHER branch
builds, the 404, and that the PDF control it copies is still there.

Thanks to TWPaMWang and xet7 !
2026-08-11 19:02:58 +03:00
github-actions[bot] 3b6b601085 Bump versions for v10.82 2026-08-11 14:44:14 +00:00
Lauri Ojansivu fcc3951f40 Prepare v10.82 release 2026-08-11 17:43:11 +03:00
Lauri Ojansivu 418ea838bc CHANGELOG: the two developer-facing fixes, and the summary they were missing from.
Every commit since v10.81 now has an entry. The two that did not were both about
a build or a test run rather than about WeKan, so they go in a developer-facing
subsection at the end, which is where the format puts them:

  the database-conformance stage opening a debug port nothing in it uses, so an
  unrelated FerretDB on 8088 made every backend report a database problem that
  was not one;

  two more mongo42 give-up paths deleting the directory their own stage filter
  names, which on amd64 and arm64 would end the snap build the same way it ended
  all four Launchpad ones.

The subsections now read as one sentence in the order the format asks for -
CRITICAL security, then the new feature, then the bugs, then these - and the
summary paragraph accounts for them instead of stopping at the bug fixes.

Thanks to xet7 !
2026-08-11 17:42:43 +03:00
Lauri Ojansivu 09a525ae83 Conformance: do not open the debug port nothing in the run uses.
From the 2026-08-11_16-55-04 run, every backend failed before a single query was
compared:

    Failed to create debug handler ... listen tcp 127.0.0.1:8088:
      bind: address already in use
    ERROR sqlite      FerretDB did not start on this backend
    ERROR postgresql  FerretDB did not start on this backend

FerretDB opens a debug handler for metrics and profiling at 127.0.0.1:8088 by
default and EXITS when that address is taken. An unrelated FerretDB was running
on the machine, so the conformance stage reported a database problem that was
nothing of the sort - and would have reported it for any developer with anything
on 8088.

The script already takes exactly this seriously for the two ports it knows about:
it picks a free wire port, makes both overridable, and says in its own comment
that they are "chosen so this can run WHILE something else is running". The debug
port was simply never passed, so it always took the default. Nothing in the run
queries it, so the fix is not to find it a free port but not to open it:
`--debug-addr=-`, which is how FerretDB's main.go spells that
(`if addr := cli.DebugAddr; addr != "" && addr != "-"`). All four handler
launches pass it.

Verified against the built binary with 8088 deliberately held: without the flag
FerretDB does not come up and logs that error, with it it starts and logs
nothing.

tests/conformanceDebugPort.test.cjs pins it for all four backends, that the wire
port is still chosen to be free (the care that was already there), and that the
reason stays written down beside the other ports - the next person choosing one
needs to know there is a third.

Thanks to xet7 !
2026-08-11 17:35:48 +03:00
Lauri Ojansivu 68dbbd8b47 CHANGELOG: collapsible checklists and card sections.
A new-feature subsection between the CRITICAL security one and the bug fixes,
which is where the format puts features, and a line in the summary paragraph.

Thanks to czinkos, MikeRatcliffe, JannetGen and xet7 !
2026-08-11 16:43:19 +03:00
Lauri Ojansivu 1cef5ede9a Collapsible checklists and card sections, on the opened card and the minicard.
#1591, open since 2018: "It would be great to have collapsable checklists on
cards" - "Long checklists can make a card pretty cluttered, so being able to
collapse them and expand only when needed would keep the board much cleaner."

WeKan had something adjacent and it is NOT this. A checklist carries
`hideAllChecklistItems`, reachable through a toggle switch inside the checklist
actions popup - but that is a field ON THE CHECKLIST, so flipping it changes what
everyone on the board sees, and it is an edit to the card rather than a view
preference. Wrong thing, and buried where nobody folding a list would look. It is
untouched here; it has its own uses (showChecklist, isFinished).

Collapsing is per-user, which WeKan already says twice in its own models:
lists.js "collapsed state is per-user only, stored in user profile.collapsedLists"
and swimlanes.js the same for swimlanes. So this follows them:
profile.collapsedCardSections[cardId][sectionKey], beside those two.

ONE map for every foldable thing on a card, because they are the same question. A
feature group uses its own section name ('labels', 'members', 'due', ...); an
individual checklist uses 'checklist-<id>', which is why folding a checklist on
the opened card folds it on the minicard too - the same key, both places.

The control is a caret on the title, not a third entry in a menu: the whole point
is to fold at a glance while reading the card. It points down when open and right
when folded, carries aria-expanded, and answers Enter and Space, because it is an
<a> with no href and would otherwise be unreachable without a mouse. The click
stops propagation - on the opened card the title opens the inline rename form,
and a minicard is a link to the card.

For the sixteen feature groups on the opened card this is done ONCE, with a
delegated handler and CSS, rather than by editing sixteen templates - and it had
to be, because they are not uniform inside: all of them open with an
`h3.card-details-item-title` but only three wrap what follows in a
`.card-details-item-content`. So the title is the handle and folding hides every
sibling after it, which works whatever a section puts there. The caret is drawn
by CSS on the title for the same reason.

Two things this is careful about. A checklist's progress bar stays visible when
folded - it is the summary of what was folded away, and hiding it too would leave
a title that says nothing. And the fold survives reopening the card: the click
toggles the class and stores it, but a fresh render only has the stored state, so
cardDetails re-applies it in an autorun.

`float: inline-start` and logical margins on the caret, so it sits on the reading
-start side under dir=rtl - tests/rtl.test.js enforces that across component CSS
and caught a physical `float: left` here.

tests/checklistCollapse.test.cjs pins the per-user placement against its two
siblings, that the setter writes the USER and never the checklist, that "never
set" stays distinguishable from "expanded", the caret's state and keyboard
reachability, that the minicard uses the same key, and that a folded section hides
the siblings rather than a content element that most sections do not have.

Thanks to czinkos, MikeRatcliffe, JannetGen and xet7 !
2026-08-11 16:41:48 +03:00
Lauri Ojansivu 71992ef546 CHANGELOG: the queue - the snap markers, notifications, card click, dates and template containers.
Five entries and a rewritten summary paragraph, which now leads with WhereBleed
and names the snap upgrade that never retried.

Thanks to Philippe-Bentegeac, JDeepix, imlit, Nissulya, csonkaoszimt, Heart1010
and xet7 !
2026-08-11 16:31:33 +03:00
Lauri Ojansivu 80f5518724 Remove the Templates containers made for accounts that never used them.
From the cpu-monster email feedback: FerretDB at 190-350% CPU on an instance with
14490 boards, of which 13404 are template containers, for 9264 accounts of which
478 have ever logged in.

Before v10.00 every new account got a "Templates" container board at signup
whether or not the person ever saved a template. That was made lazy in v10.00
(#2339, #5850) - `ensureTemplatesBoard` creates one only when a template is
actually saved - so no NEW account creates one. But nothing ever removed the ones
already made, and they are not visible enough for anybody to delete by hand. On
that instance they are 13x the boards collection, and every query that touches
boards carries it; under polling reactivity that is every poll.

THIS DELETES BOARDS, so the rule for what may go is the whole of the risk and is
deliberately narrow: only a container nobody ever used. Anything at all having
been done to one keeps it - a template saved into it, a list, swimlane or card
added, a second member, a rename, a star, a manual archive. The rule is in
models/lib/unusedTemplateContainers.js on its own, so it can be read and tested
without the method around it, and every kept board comes back with the reason it
was kept.

A rename is judged only against the titles the app itself used, supplied by the
caller: that string is translated and has changed over time, so with no list to
compare against the title is not judged at all rather than a board being deleted
because its default name is in another language.

And it does nothing unless asked twice. The default is a dry run reporting what
WOULD go, with a sample rather than thirteen thousand rows; `apply: true` is what
deletes. For a collection this size that is the difference between a cleanup and
an accident. Admin only, checked before it reads anything. The
`profile.templatesBoardId` pointer is cleared with the board, or the next
ensureTemplatesBoard trips over a board that is not there, and one failed
deletion does not abandon the rest - the ones that failed are reported.

tests/unusedTemplateContainers.test.cjs is mostly about what must NOT be removed:
each of the four things that can be inside one, each of the four ways a person can
have claimed one, every non-container board type, junk input, and that the dry run
returns before anything is deleted.

Thanks to xet7 !
2026-08-11 16:28:38 +03:00
Lauri Ojansivu 097ad9160a Snap: a new revision is a new chance, so the 4.2 reader actually gets to run.
#6471, reopened against 10.81: "on 10.81 I still have the exact same issue,
MongoDB cannot start. The web interface is still unreachable, and I do not see
the messages you added in the last commits."

The messages were not printed because the code that prints them never ran. The
4.2 reader added for this issue was in their snap and was never reached, because
two markers in $SNAP_COMMON are PERMANENT:

  .mongodb-data-too-old   written when no reader in the snap could open the data.
                          mongodb-control then refuses to start mongod at all,
                          and wekan-control serves an explanatory page instead of
                          WeKan.
  .mongod-start-failures  a counter; past three, mongodb-control stops re-running
                          the MongoDB -> FerretDB migration, so a
                          migration<->mongod restart loop cannot form.

Both are right, and both record a conclusion about what THAT SNAP could do. An
instance that had already failed on 10.79 or 10.80 - before mongod 4.2 was
bundled - carried a marker saying "no reader can open this" and a counter far
past three. So on 10.81 mongod was never started, the migration was never
attempted, and nothing new was printed. Upgrading to the version with the fix
changed nothing, which is exactly what their log shows: the database-selection
line, then "Waiting for MongoDB replica set primary..." repeating forever.

A conclusion about an older revision is not an answer about this one. Each marker
now records the revision that wrote it (snap-src/bin/stale-marker), and a marker
from a different revision is ignored and cleared, so the snap tries again with
whatever it can do now. A marker with no revision recorded at all is stale by
definition - that is precisely what the affected instances are carrying.

Within one revision nothing changes: same revision, same refusal, same three-try
loop protection. Not knowing the revision (running outside a snap) is never
treated as evidence that it changed, so nothing is cleared on a guess.

Three places consult it. mongodb-control clears both the marker AND the counter
before deciding - an instance that gave up on an older revision has both, and
leaving one latched fixes nothing. wekan-control makes the same judgement because
it can start first and would otherwise keep serving "your data is too old for this
snap" on a snap that has not tried. migration-control stamps the marker when it
writes it. A mongod that does start still clears the counter and its stamp
together.

tests/snapStaleMarker.test.cjs runs the helper for the reported case, the same
revision, the no-stamp case, and the two ways of not knowing, then pins all three
call sites.

Thanks to Philippe-Bentegeac, JDeepix, imlit and xet7 !
2026-08-11 16:24:52 +03:00
Lauri Ojansivu 6420819cfa Clicking an open card closes it, and a focused checkbox is not a diamond.
The two newest comments on #6465.

CLOSING BY CLICKING AGAIN was not a missing feature - it was an unreachable one.
clickOnMiniCard already ended with

    } else if (Session.equals('currentCard', card._id)) {
      Utils.goBoardId(Session.get('currentBoard'));

but the TITLE branch above it returns before that is ever reached, and a
minicard's title covers most of the minicard. So the second click almost always
landed in the title branch, which unconditionally re-opened the card that was
already open. The toggle worked only if you managed to click a part of the card
that was not its title, which is why two people asked for a feature that was
already written.

The title branch now asks the same question first, and closes the same way. A
click on a card that is NOT open still opens it, and still clears the phone popup
ids as it did. On a mini screen the card is a popup rather than a pane, so
"already open" is a different question - `popupCardId` and `Popup.isOpen()` - and
that path closes with `Popup.back()`. Multi-selection still short-circuits both,
so ticking cards is unaffected.

THE PROBLEMS PAGE GLITCH in the screenshot is the focus ring. The Admin Panel
draws its checkboxes as a material square that morphs into a tick, and the tick
IS a 40-degree rotation of the element. A browser draws its focus ring around the
element as it is - rotated - so a checkbox that is both checked and focused, which
is exactly what a checkbox you just clicked is, comes out as a blue DIAMOND where
the tick should be.

The ring is not removed: keyboard users need to see where they are. It moves to
the row that contains the checkbox, which is not rotated - every one of these sits
inside a label (problemsSummary.jade) - so it is a normal rectangle around the
whole row, which is easier to see than a 13px box anyway. `:focus-visible`, so a
mouse click does not leave a ring behind.

tests/cardClickToggle.test.cjs pins that the close decision comes before
re-opening (or it never happens), that opening a different card and the existing
non-title toggle both still work, that multi-selection still wins, that the tick
is still the rotation, and that the label nesting the ring relies on is really
there.

Thanks to csonkaoszimt, Heart1010 and xet7 !
2026-08-11 16:19:43 +03:00
Lauri Ojansivu 184e1713d3 Notifications: cap the tray array, which SQLite was being rewritten by.
#6533. A snap instance reports FerretDB at 737% CPU, logins taking over a minute,
boards not appearing, and logs full of:

    [db-retry] the database was busy: 0 write(s) retried, 0 succeeded on a retry,
      3 given up on.
    ValidationError: ... [collection.go:191 sqlite.(*collection).UpdateAll]
      database is locked (5) (SQLITE_BUSY)
      at _helpersConstructor.addNotification (models/users.js:2248)

The stack names the same write every time. addNotification is an `$addToSet` on
`profile.notifications`, which is an array INSIDE the user document: adding one
entry means reading the whole document, scanning the array for a duplicate, and
writing the whole document back. The cost is proportional to the array. FerretDB
on SQLite has a SINGLE writer, so those rewrites queue behind each other and
start failing outright - and login, which also writes to the user document,
queues behind them. That is the slow login, the boards that never arrive and the
pinned CPU, all from one array.

WHY IT GROWS WITHOUT LIMIT: the cleanup added in #5685 removes a notification only
when every entry for its activity has been READ and is past its removal age. A
user who does not clear their tray accumulates entries forever, and nothing else
in the product shrinks that array. The instance in the report has been running
since at least 2026-07-29 with contention warnings, which is what an array that
only ever grows looks like from the outside.

So the same cleanup pass now also caps it: the newest NOTIFICATION_TRAY_MAX_PER_USER
entries are kept (default 1000, a backstop rather than a retention policy) and the
rest dropped. Newest rather than oldest because `$addToSet` appends, so the tail
is the recent activity a tray is for; an unread notification from months ago is
not something anybody is going to act on.

Three things it is careful about. The cap is applied to what is LEFT AFTER the
expiry pull, not before, or it would trim entries that were about to be removed
anyway and keep fewer than the cap. A user needing neither change is not written
to at all - the whole problem here is writes to user documents, so the cleanup
must not add one per user per day for users with nothing to do. And it stays ONE
write per user, which is what #5685 collapsed K writes into; the capped case uses
`$set` because a `$pull` would have to list the entries it is removing, and there
being too many of them is the point.

This bounds the array. It does not make SQLite a multi-writer engine: an instance
this busy still wants the PostgreSQL backend, which is what the Problems pane
already tells the admin.

tests/notificationCap.test.cjs pins the decisions - keeping the newest, leaving an
array under the cap completely alone (so no write), refusing to act on junk or a
disabled cap, that unread entries are what this catches because the existing rule
provably cannot, and that it is still one write per user.

Thanks to Nissulya and xet7 !
2026-08-11 16:15:06 +03:00
Lauri Ojansivu 0c5cb99b7c Card dates: a typed two-digit year is refused, not stored as the year 26.
Email feedback: "If i write the expiration date with the keyboard it turns red,
if i choose it with the date picker it is yellow. Can you please tell me the
difference?"

It is a bug, and the colour was never it. `<input type="date">` reports its value
as YYYY-MM-DD, but a browser lets the YEAR sub-field be typed as two digits and
reports exactly that: entering 31-12-26 gives "0026-12-31", the year 26 AD. That
is a perfectly valid Date, so nothing refused it, and the card was saved with a
due date two thousand years in the past. Red is what an overdue date is supposed
to look like. The same day chosen from the calendar always fills four digits, so
it is 2026 and yellow, because it is only due soon.

The attached screenshot shows it rather than the colours: the yellow badges read
"31-12-2026 17:00" and the red ones "31-12-26 17:00" - two different years.

Saving now refuses a year outside 1000-9999 with "Invalid year. Please type all
four digits, for example 2026." and puts the cursor back in the date field. The
window is deliberately wide: WeKan holds real historical received dates and
long-dated deadlines, and this is only meant to catch a year that cannot have
been intended.

REFUSED, NOT CORRECTED. 0026 was almost certainly meant as 2026, but silently
rewriting a date somebody typed - one that other people's due-date reminders hang
off - is a guess in the wrong place. The message says which digits are missing,
which is the thing the reporter could not see.

tests/datePickerYear.test.cjs pins the reported value the browser really hands
over, the date that was meant, the width of the window either side, that the
refusal happens before storeDate, and that nothing rewrites the year.

Thanks to xet7 !
2026-08-11 16:07:47 +03:00
Lauri Ojansivu b6d0be0f45 Snap: the other two mongo42 give-up paths deleted their own staged directory.
The earlier fix for the stage filter covered one of three ways this part gives
up - the unsupported-architecture `exit 0`. The other two did `rm -rf "$dest"`
and exited 0, which leaves `stage: mongo42` naming a path that is not there, and
snapcraft ends the whole build on that:

    Failed to copy '/build/.../parts/mongo42/install/mongo42': no such file or directory

Those two are reached on amd64 and arm64, where the binary IS downloaded: OpenSSL
1.1 could not be obtained for the architecture, or the staged mongod 4.2 failed
the smoke test that checks it actually runs. Either would have ended the snap
build for the two architectures that matter most, in the same way it ended all
four Launchpad ones.

Both now remove the CONTENTS and keep the directory. An empty mongo42/ stages to
nothing, and migration-control guards every use on `-x "$M42/bin/mongod"` - the
binary, not the directory - so "no 4.2 reader here" is still exactly what an empty
one means. Checked: that is the only guard, so an empty directory cannot be
mistaken for a working reader.

tests/snapPartFilters.test.cjs now requires every give-up path to clear contents
only, rather than pinning the single shape the first failure happened to take.

Thanks to xet7 !
2026-08-11 15:59:29 +03:00
Lauri Ojansivu 3ebc38cb40 CHANGELOG: WhereBleed leads the release.
A CRITICAL SECURITY section comes first, so the bug subsections that follow it
become "and fixes ...". The summary paragraph leads with it too, in plain text -
the In short paragraph links nothing; the entry does.

Thanks to TungNGo02 and xet7 !
2026-08-11 15:57:11 +03:00
Lauri Ojansivu b4ebe48d7b WhereBleed: eight Admin Panel handlers ran the caller's selector unchecked.
GHSA-phm4-4v26-j2vq, CWE-943, CVSS 5.8. The Admin Panel's people, org, team and
translation publications and their companion count/page methods take a query
selector from the client and validate only its TYPE:

    check(query, Match.OneOf(Object, null));

which is not validation, because a MongoDB selector is executable data. `$where`
makes the database run the caller's JavaScript once per document scanned, so

    Meteor.subscribe('team', { $where: 'while(true){}' }, 25, 0)

pins a database worker for as long as the caller likes and is repeatable: denial
of service for every tenant on the instance, from one narrowly-scoped account.
The reporter demonstrated both halves on v10.81 against mongo:7 - `$where:
'sleep(2000) || true'` made the `people` subscription take 2.03s and return the
document, `$where: 'false'` returned nothing in 0.00s - which is the caller
deciding in JavaScript which documents come back.

It needs an authenticated admin session, so it is not reachable by a board member
or a visitor. It is still worth the fix at this severity because the people and
org surfaces are open to a PER-TENANT Global Admin, a role meant to be confined
to one Organization, and those two wrap the caller's selector as
`{ $and: [query, restriction] }` rather than stripping execution operators out of
it - so merging the tenant restriction never removed the `$where`, and a role
scoped to one tenant reached instance-wide impact.

WHAT MAKES THIS ONE PARTICULAR is that the defence was already here. classifySelector
(models/lib/injectionDetect.js) and hasWhere (models/lib/mongoSelectorSafety.js)
were written for exactly this class and were wired into the card-window
publication, with unit tests. Eight sibling handlers taking the identical shape of
client-supplied selector simply never called them. So this adds no new detection
logic: cardsWindow.js's own helper moves, unchanged, to
/server/lib/selectorGuard.js, and all nine call sites share the one copy. A second
copy of the check would be the same bug set up to happen again, which is why
cardsWindow.js now imports rather than keeps its own.

Each handler refuses with the "match nothing" selector that publication already
uses in production, `{ _id: { $in: [] } }` - frozen, so a caller cannot mutate the
shared object into one that matches - so a refused request returns an empty result
instead of throwing at an admin mid-page, and the canary still records who sent
what from where. Ordinary Admin Panel behaviour is untouched: none of the
searches, filters, regexes, $or/$and/$in/$elemMatch or date ranges those panes
send contains an execution operator, and the test pins fourteen real ones going
through unchanged.

FerretDB, WeKan's default database, rejects $where itself, so this degrades to a
rejected query there; the supported real-MongoDB path
(docker-compose-mongodb-v7.yml) is where it was reproduced. MongoDB 7 also happens
to reject $where inside the aggregation pipeline the four count methods use, but
that is an engine-level accident for one operator on one call path - $function and
$accumulator are legitimate aggregation operators - so the count methods are
guarded like the rest rather than left to it.

tests/selectorGuard.test.cjs pins the reported payloads (including nested inside
$and/$or, which is how the tenant merge would carry them), the other execution
operators, the fourteen ordinary queries, that all nine call sites route through
the guard, that none still passes the raw selector afterwards, and that exactly
one copy of the check exists. Three existing suites asserted the old shape -
canaryCoverage on where the canary trips, injectionDetect on the helper living in
cardsWindow.js, tenantWiring on the scope function receiving the raw query - and
each is updated to the new one, saying why.

Thanks to TungNGo02 and xet7 !
2026-08-11 15:50:31 +03:00
Lauri Ojansivu 09ce64ecf6 CHANGELOG: the snap database copy and the card drag scroll.
The two issues reported after upgrading from v6: a FerretDB migration that had
fallen weeks behind the MongoDB beside it and was being served in its place, and
a card drag that scrolled the board instead of the list because the vertical
auto-scroll never picked the list under the pointer.

Thanks to markusst1982 and xet7 !
2026-08-11 15:34:16 +03:00
Lauri Ojansivu 8456170d45 Dragging a card down scrolls the list, not the whole board.
"With v6 i was able to move a Card downwards by drag & Drop. Upwards is no
   problem, the Line scrolls automaticly up, but this does not work downwards.
   The whole Page/Site scrolls down and not ne Line"

The card sortable's `sort` handler auto-scrolls at the edges. HORIZONTALLY it
picks the lane under the pointer (#443). VERTICALLY it picked nothing: it always
scrolled `.board-canvas`, which is `overflow-y: auto` and holds the swimlanes,
rather than the `.list-body` under the pointer, which is `overflow-y: scroll` and
holds the cards. Scrolling the canvas moves the whole board - which is the report,
in the reporter's own words.

The asymmetry is what makes it reproducible rather than vague. Dragging UP, the
canvas is usually already at scrollTop 0, so computeEdgeScroll returned null, the
handler did nothing, and jQuery UI's own `scroll` option - which acts on the
placeholder's scrollParent, i.e. the list body - scrolled the list, which is why
"upwards is no problem". Dragging DOWN, the canvas nearly always has room left,
so the handler fired first and scrolled the board instead of the list.

The list body under the pointer is now scrolled first, found with the same
rectangle hit-test the lanes use, and the canvas only when the list cannot go
further that way - so a drag down a long list scrolls the list, and a drag past
the end of it moves on to the board, which is what dragging a card to another
swimlane needs. The horizontal lane scroll is unchanged, and the list branch sets
`scrolled` so the #6477 refreshPositions still runs after it: a manual scroll
invalidates jQuery UI's cached geometry whichever element was scrolled.

tests/cardDragVerticalScroll.test.cjs pins the order (list before canvas, canvas
only when the list did not move) and runs computeEdgeScroll with real numbers for
the three cases that decide it: a pointer in the bottom edge zone of a scrollable
list, the same at the end of its scroll range, and a list that does not overflow.

Thanks to markusst1982 and xet7 !

Fixes #6584,
2026-08-11 15:32:47 +03:00
Lauri Ojansivu b1e7b1e538 Snap: never serve a FerretDB copy that is older than the MongoDB beside it.
After upgrading from v6 the reporter saw "the state of the data from days ago"
and suspected a `snap revert` done four weeks earlier. They were right about the
cause, and nothing was lost.

The MongoDB -> FerretDB migration copies MongoDB into SQLite ONCE and writes
.migration-to-ferretdb-done. That copy is a snapshot; nothing keeps it in step.
Revert the snap to a revision that runs mongod and WeKan carries on writing to
MongoDB - for four weeks here - while the finished SQLite sits frozen at the date
it was made. Refresh forward again and mongodb-control's
switch_to_completed_ferretdb saw a marker plus a non-empty SQLite, called that a
completed migration, and switched the snap onto it. Every board and card written
during the revert was still on disk and simply not being served.

The data was never in danger: it lives in $SNAP_COMMON, which is shared across
revisions and is NOT rolled back by a revert (unlike $SNAP_DATA, which is
per-revision). What was wrong was WHICH of the two copies got served, and nothing
compared their ages.

snap-src/bin/ferretdb-migration-stale answers that one question: is the completed
migration older than the MongoDB data it was made from? mongod rewrites its
WiredTiger files whenever it commits, so the newest mtime among them is when
MongoDB was last written to; later than the marker means the copy is behind. It
counts DATA files only - a newer mongodb.log or .pid means the snap was started,
not that the database changed - and allows a margin, because the migration stops
its own temporary source mongod moments after writing the marker and that
shutdown touches the data directory. Anything it cannot tell (no marker, no
SQLite, no MongoDB data, unreadable mtimes) is reported as NOT stale: the callers
act on a yes, so being unsure has to leave the existing behaviour alone.

Three places act on it:

  mongodb-control's switch_to_completed_ferretdb refuses a stale migration and
  leaves MongoDB running, which has the newest data.

  mongodb-control's handle_mongod_start_failure - where mongod will not start at
  all, so keeping MongoDB is not on offer and the choice is between a fresh copy
  and a stale one - migrates AGAIN. The migration reads with its own temporary
  mongod and the 4.2/3.2 readers, none of which is the mongod that just failed,
  so it can still reach everything written since. It has to be restarted rather
  than resumed: migration-control exits immediately while the marker is there,
  and the checkpoint and old SQLite describe a finished copy of the old data, so
  the marker, the checkpoint and files/db are cleared first. The MongoDB data is
  never touched.

  wekan-control gains the second half of a guard it already had. It refused to
  start an EMPTY FerretDB while MongoDB had data; a FULL but out-of-date one looks
  worse, because WeKan comes up with boards and cards all present and only the
  last weeks missing, which reads as data loss rather than as a misconfiguration.
  It now stays on MongoDB and says why, including that nothing is lost.

tests/ferretdbMigrationStale.test.cjs runs the detector against synthesised
$SNAP_COMMON directories - the reported four-week case, a fresh migration, the
margin either side of it, a newer log file, and each "cannot tell" - and pins the
wiring at all three call sites.

Thanks to markusst1982 and xet7 !

Fixes #6583,
2026-08-11 15:32:32 +03:00
Lauri Ojansivu dac8d1da2f CHANGELOG: the two wekan-ldap links point into wekan/wekan.
github.com/wekan/wekan-ldap no longer exists - the API redirects and then answers
422 - so the two links in the v2.52 (2019) entry for "Added and then reverted
option to redirect OIDC OAuth2 login" were dead.

The HASHES do not carry over. The wekan-ldap code was copied into
packages/wekan-ldap on 2019-04-20 ("Include to Wekan packages directory
contents"), not history-merged, so 82a894ac and 36900cc exist in no repository
now: GitHub answers 422 for both under wekan/wekan, and neither object is in this
clone. Repointing the URL alone would have produced a link that still 404s.

What DOES exist is the pair of wekan/wekan commits that recorded those two steps
on the same day, and the first of them is certain rather than inferred - its
commit message IS the old link:

  part1  82a894ac  ->  188d42dcd  "[Add option to redirect OIDC OAuth2 login]
                                    (.../wekan-ldap/commit/82a894ac...)"
  part 2 36900cc   ->  d173844f9  "OAUTH2_LOGIN_STYLE popup or redirect,
                                    part 2 and 3."

So a reader following part1 still sees the original wekan-ldap hash, in the
message of the commit they land on. part3 is unchanged: 7919ae362 is in
wekan/wekan already and is the code change of that day (Dockerfile,
docker-compose.yml, server/authentication.js, the snap config).

Both targets checked against the GitHub API before use: HTTP 200.
releases/fix-changelog-hashes.sh --all-sections now reports all 3235 commit links
resolving.

Thanks to xet7 !
2026-08-11 15:18:26 +03:00
Lauri Ojansivu c452a2defd CHANGELOG: the canary-tokens entry links FerretDB, where that commit lives.
releases/fix-changelog-hashes.sh --all-sections checked all 3234 wekan/wekan
commit links and found exactly one that resolves to nothing:

  https://github.com/wekan/wekan/commit/a260a53e

It is not a rewritten hash, so there was nothing for the script to repoint - it
reported it and left it, which is what it should do. The hash is fine; the
REPOSITORY in the URL was wrong. The entry describes `internal/util/canary`,
which is a FerretDB path, and a260a53e is "Canary tokens: mark the operations the
client never issues." in wekan/FerretDB - confirmed both in .tools/FerretDB and
against the GitHub API, which serves it there and answers 422 for it under
wekan/wekan.

Thanks to xet7 !
2026-08-11 15:13:20 +03:00
Lauri Ojansivu c8393f3f53 CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.

Thanks to xet7 !
2026-08-11 11:36:19 +03:00
Lauri Ojansivu be558257cd CHANGELOG: the image-check work belongs to the next release, not to v10.81.
v10.81 was prepared, tagged and published (at "Bump versions for v10.81") while
this was being written, so the entry describing the chart-index image check was
added into a section for a release that had already gone out without it. A
release section has to describe what is IN that release; it is moved to a new
Upcoming section, where the change will actually ship.

What stays in v10.81 is what v10.81 really carries: the FerretDB
directConnection fix, All Boards on a phone, the retired Mac runner and the jobs
a cancellation skipped, the armv6 userland, the Launchpad snap part, and the two
chart commits that were pushed before the release was prepared.

Also: the new "is there an image for this version" check imports
releases/reindex-charts.py, and importing a module writes releases/__pycache__
beside it - untracked junk appearing in the repository on every release. It runs
with PYTHONDONTWRITEBYTECODE=1, verified to leave nothing behind.

Thanks to xet7 !
2026-08-11 11:36:18 +03:00
Lauri Ojansivu 7fc1f35bed CHANGELOG: link the image-check entry to its commit.
Thanks to xet7 !
2026-08-11 11:36:18 +03:00
Lauri Ojansivu a442959dda Charts: list only the ones whose container images still exist.
Backfilling the index taught this within the hour. Artifact Hub scans every entry
in a Helm index and mailed a list of errors:

    error scanning image ghcr.io/wekan/wekan:v9.62: image not found
    error scanning image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found

That was this side's doing, not Artifact Hub's. The rebuild listed every package
sitting on gh-pages, and a chart is a POINTER TO CONTAINER IMAGES: one whose
images have been deleted from their registries installs and then fails at the
pull, so listing it announces that the repository is broken when the repository
is fine and the images are gone.

135 of the 360 packages are in that state, from two unrelated causes. SIX have no
WeKan image - v8.30, v9.12, v9.14, v9.38, v9.39 and v9.62, releases whose own
docker job failed, and exactly the six ghcr.io errors that were reported. The
other 129 vendor the BITNAMI mongodb subchart and pin a tag Bitnami has since
deleted; charts from 8.41 on vendor groundhog2k's mongodb, which uses the
official `mongo` image and is unaffected.

The index now holds 225 entries: every one of the 216 that were listed before -
not one dropped - plus the 9 backfilled packages whose images all resolve. Twelve
listed charts were spot-checked against the registries afterwards and every image
pulled.

THE EXCLUSIONS ARE A FILE, unindexed.txt, beside the packages - not a check made
on every run. A rebuild during a release must not depend on reaching two
registries, where a blip would silently drop half the index; and a recorded
decision is reversible and reviewable, so an image that comes back is one deleted
line away from being listed again. The .tgz files all stay, so direct URLs keep
working. `--check-images` is what regenerates the file.

Two things this shook out, both about being careful with a negative result.

The registry handshake is now the REGISTRY'S OWN: ask for the manifest with no
credentials and let the 401 say which realm and scope to use. The first version
hard-coded a token URL per host and reported every quay.io image as missing -
including quay.io/wekan/wekan:latest, which plainly exists - because the wrong
endpoint returned a token quay would not accept and the manifest came back 404.
Four charts were nearly dropped on that. Related: a repository that already
begins with a host ("ghcr.io/wekan/wekan") must not be read as a docker.io path,
which is how a first pass came back "unknown" for every WeKan image.

And an image that cannot be CHECKED is never treated as missing - only a definite
404 excludes a chart. A registry that refuses anonymous auth or a request that
times out must not be able to silently unpublish anything.

release-charts.sh now refuses to publish a chart when
ghcr.io/wekan/wekan:v<version> does not exist, which is what created those six in
the first place. It checks before it touches the charts repository, and a
registry it cannot reach does not block a release.

Thanks to xet7 !
2026-08-11 11:36:18 +03:00
github-actions[bot] b6d003089c Bump versions for v10.81 2026-08-11 00:02:26 +00:00
Lauri Ojansivu fc1e089d38 Prepare v10.81 release 2026-08-11 03:01:27 +03:00
Lauri Ojansivu 35b44a1f8b CHANGELOG: link the chart-publishing entry to its commit.
Thanks to xet7 !
2026-08-11 02:54:55 +03:00
Lauri Ojansivu a434bb4bd5 Charts: both release workflows publish one, and the index is derived not edited.
The charts repository keeps the chart SOURCE on main and the published packages
plus index.yaml on gh-pages, and its own two scripts move between them: main's
release.sh commits the source, tars `wekan/` into wekan-<version>.0.tgz, checks
out gh-pages and drops the package there; gh-pages' release2.sh commits and
pushes it. release-charts.sh drives both and owns the index in between, and that
middle part is what changes here.

THE INDEX IS REBUILT FROM THE PACKAGES, not edited in place. It used to copy the
newest entry, substitute a few fields and prepend the result, and every one of
the index's four defects came from that: a re-run prepended a second entry for a
version instead of replacing it (9.36.0 ended with four, each a different digest,
so a helm client could pick one matching no file); appVersion was set to the
CHART version rather than the WeKan version on every entry; fields nobody
substituted never changed, so every entry claimed the mongodb subchart was 0.7.2
while the packages had moved to 0.7.6; and a release whose charts job did not run
left a hole nothing ever went back for. Deriving the index makes all four
impossible instead of repaired - one entry per package, its digest computed from
the file, its fields read out of the archive, and any package that was missed
picked up on the next run.

THE PACKAGE IS CHECKED AGAINST ITS OWN FILENAME FIRST. release.sh names the
tarball from its argument (`tar -cvzf wekan-$1.0.tgz wekan`) while the version
inside comes from the Chart.yaml this script sed-ed a moment earlier. When those
drift the repository gains a file called one version that declares another, and a
Helm index keys on the version INSIDE - so it can never be installed under the
name it was given. That is how wekan-1.2.7.tgz (containing 1.2.6) and
wekan-6.96.tgz (containing 6.9.6) came to exist; both are removed from the charts
repository, and a package like them now stops the release with a message naming
both versions rather than being published.

RELEASE ALL MISSING PUBLISHES A CHART TOO. It could rebuild any missing bundle
but not a missing chart - its header said the charts were out of scope - so a
release that never got a chart never would. It has a charts job now, and it is
the one part of that workflow which pushes to another repository, so it is
guarded twice: nothing happens without a WEKAN_REPO_TOKEN that can push to
wekan/charts (a named warning and a skip, not a failure inside git), and nothing
happens when the published index already lists that version, because
re-publishing would re-tar the package and change the digest of a chart people
have already pulled. Both workflows publish through the same
releases/release-charts.sh, so neither can drift from the other.

release-charts.sh also finds the checkout at .tools/charts, where CLAUDE.md keeps
companion repositories, before the older ../w/charts.

Verified against the real checkout: a package dropped in is picked up, sorted
first, with its appVersion and digest read from the archive - then removed and
the index restored. tests/releaseChartsIndex.test.cjs pins that the index is
derived rather than edited, that the filename check runs BEFORE indexing, that
both workflows publish through the same script, and that an already-published
version is not republished. It runs the script's own version-reading code against
a package named 1.2.7 that declares 1.2.6.

Thanks to xet7 !
2026-08-11 02:54:47 +03:00
Lauri Ojansivu 292ffb8906 CHANGELOG: the index.yaml rebuild entry.
Wrapped at 80 and repointed by releases/fix-changelog-hashes.sh after the amend
that rewrote its commit.

Thanks to xet7 !
2026-08-11 02:44:40 +03:00
Lauri Ojansivu 1a2ec06332 Charts: rebuild index.yaml from the packages the repository actually serves.
With wekan/charts cloned at .tools/charts, the index could be compared against
its own packages for the first time, and it had drifted from them in four ways at
once. All four come from the same habit: release-charts.sh writes an entry by
COPYING the previous entry and substituting a few fields, so the index was kept
as the record rather than as a description of the .tgz files beside it.

  146 PACKAGES HAD NO ENTRY. 362 packages, 220 entries. Those charts are
  downloadable by URL and invisible to `helm search`, to
  `helm install wekan --version 9.63.0` and to Artifact Hub. An entry is written
  once, during the release it belongs to, so a release whose charts job did not
  run left a hole and nothing ever went back for it.

  DUPLICATE ENTRIES WITH STALE DIGESTS. 9.36.0 had four entries and 10.30.0 two,
  each copy with a different digest and the same url, because a re-run of a
  release prepended a second entry instead of replacing the first. Only one
  digest per version can be the digest of the file served - the other four match
  no package in the repository - so a helm client that picked one of them failed
  the integrity check on a perfectly good file.

  A WRONG appVersion ON EVERY ENTRY. The index gave the CHART version there
  ("10.79.0") where the packaged Chart.yaml says the WeKan version ("10.79"),
  which is the one a reader is actually looking for.

  FROZEN DEPENDENCY VERSIONS. A field nobody substituted never changed: every
  entry claimed the mongodb subchart was 0.7.2 while the packages have been 0.7.6
  for some time.

releases/reindex-charts.py derives the index from the packages, reading each
one's Chart.yaml out of the archive - so an entry cannot describe a package
wrongly - and needs no helm, only python3.

Nothing is invented and nothing is lost. `created` is preserved exactly on every
entry whose digest matches its package; a package being added takes the date it
was committed to gh-pages, which is when it was really published (wekan-10.79.0
was committed at 2026-08-10T02:46:52Z and its entry already said created
2026-08-10T02:46:52.308983+00:00). Two 2023 packaging slips - wekan-1.2.7.tgz
containing chart 1.2.6, and wekan-6.96.tgz containing 6.9.6, each colliding with
the file whose NAME matches - are reported and left out rather than letting one
version mean two files; both files stay on the server, so a direct URL keeps
working.

Run against the real repository the index went from 220 entries to 360, and was
verified after writing: no repeated version, every digest equal to the sha256 of
its file, every file present, every url under wekan.github.io/charts, and no
version that was listed before missing.

backfill-charts.sh now looks in .tools/charts before ../w/charts, since that is
where CLAUDE.md keeps companion repositories, and both tools are Release menu
entries in build.sh and build.bat.

tests/reindexCharts.test.cjs runs the tool against a synthesised charts directory
- real gzipped chart packages, written without helm - and pins the rules that
have a decision in them: created is preserved rather than restamped, the
surviving duplicate is the one whose digest matches the file, a version claimed
by two packages is resolved by filename and REPORTED, no previously listed
version is dropped, and a report run does not touch the file.

Thanks to xet7 !
2026-08-11 02:43:19 +03:00
Lauri Ojansivu 2c7a016327 CHANGELOG: the Upcoming section for the six fixes.
Two bugs that stopped WeKan starting - the FerretDB replica-set handshake sending
the driver to 0.0.0.0, and All Boards on a phone - and four release-pipeline
failures: the retired macos-13 label that has never once built the Mac x64
bundle, the cancellation that silently skipped charts/ucs/nextcloud, the armv6
bundle assembled in a soft-float ARMv5 userland, and the optional MongoDB 4.2
part that ended every Launchpad snap. Plus the chart-index report.

The binaries table is v10.80's: nothing here rebuilds them.

Thanks to xet7 !
2026-08-11 02:19:44 +03:00
Lauri Ojansivu 18da0d2618 Snap: an optional part was ending every Launchpad build.
All four Launchpad architectures failed in v10.80 - armhf, s390x, ppc64el and
riscv64, three attempts each - and the job reported them as a Launchpad problem:
"often an OOM in the Meteor npm install, or a transient build-farm reset; re-run".
It was neither, and re-running was the one thing that could not help. The build
log, which the job prints, said the same deterministic thing every time:

    Staging mongo42
    Failed to copy '/build/.../parts/mongo42/install/mongo42':
      no such file or directory.
    Build failed

The mongo42 part downloads MongoDB 4.2 so a database from an old MongoDB snap can
still be migrated (#6471), and MongoDB publishes 4.2 for amd64 and arm64 only. On
every other architecture the part prints "nothing to migrate from there;
skipping" and exits 0 having created nothing - and its own comment calls that
"OPTIONAL BY DESIGN". It was not optional: the part also carries
`stage: [mongo42]` and `prime: [mongo42]`, and snapcraft does not skip a filter
whose path is missing, it ends the build. So a migration helper that no exotic
architecture has ever needed took the whole snap down on exactly the four
architectures that have no native runner and can only be built on Launchpad.

The directory is created before anything can decide to skip, so the filters
always have something to copy. The binary is still not downloaded anywhere but
amd64 and arm64, and migration-control still finds nothing there - "no MongoDB
4.2 to migrate from" is a missing binary again instead of a failed build.

riscv64 additionally lost its last attempt to the build farm's network:

    npm ERR! code ECONNRESET
    npm ERR! network request to .../string-width-4.2.3.tgz failed, reason: socket hang up
    'override-build' in part 'wekan' failed with code 1.

after half an hour of building. Every request there goes through Launchpad's
proxy and one tarball out of a couple of thousand can be cut mid-stream; npm's
own --fetch-retries covers the request, not a dropped socket. That npm install is
retried three times now, and still fails loudly if it is not the network.

The ::error:: no longer guesses. It sends the reader to the build log that is
already printed above it and lists what to look for in the order worth checking,
starting with a "Failed to copy .../parts/<part>/install/<path>", which is a
stage:/prime: filter naming something that architecture does not build and will
never pass on a re-run.

tests/snapPartFilters.test.cjs pins the ordering inside mongo42, that it is still
amd64/arm64 only, the npm retry, and the general rule the failure was one
instance of: a literal path in stage:/prime: must be something its part actually
produces.

Thanks to xet7 !
2026-08-11 02:16:30 +03:00
Lauri Ojansivu c0a5c2b7c9 All Boards on a phone: the scroll, the table view, the 4x tiles and the spacing.
Four things, reported together, in a issue that had been closed three times and
reopened against 10.10, 10.37, 10.38, 10.72 and 10.77.

THE SCROLL. Every fix so far removed viewport arithmetic from boardsList.css and
said so in its comments - "no viewport arithmetic here at all now". The last
piece of it was in layouts.css, where nobody looked:

    body.mobile-mode #content { height: calc(100dvh - 48px) }

48px is a guess at the height of the header. The header is not 48px and is not
any one number: Utils publishes --wekan-header-height from a ResizeObserver for
exactly this reason, and its own comment says the quick-access bar wraps to a
second and third row depending on language and window width and that "every fixed
number for it has been wrong for some of those". So on a phone whose bar wraps,
#content was TALLER than the room under the header, and its bottom sat below the
screen. body.mobile-mode is `position: fixed` and the phone rules make it
`overflow: hidden`, so that strip is unreachable, and the `height: 100%` chain
below inherited all of it: the board list scrolled, it just ended off screen, and
its last boards were where no gesture could reach them.

Nothing has to be computed. body is a flex column of exactly one viewport and
#content is its `flex: 1` item, so the space under the header IS that box, at
whatever height the header really is this paint. The calc was overriding a
correct answer with a wrong one. It is gone, and `min-height: 0` lets the chain
below actually shrink.

THE TABLE VIEW. The right column has two branches - the board icons and
`+tablePage` - and only the icons were ever given a scroller. In Table view the
table grew past the bottom of the column and `#content .wrapper` is
`overflow: hidden`, so everything past the fold was clipped with nothing to
scroll. Every section of All Boards can be shown as a table, so that was half the
page for all of them. `.table-page` now takes what the column has left and
scrolls in both directions, and the pane heading is `flex: 0 0 auto` so it keeps
its height instead of being squeezed while the list stays too tall.

THE 4x-TALL TILES. A grid defaults to `align-content: stretch` and
`align-items: stretch`, so a list SHORTER than its column has the leftover height
divided among its rows and each tile grown into it - and a board tile paints its
board colour over the whole cell, so one row of boards became one band several
times a normal icon's height. This was already fixed once, but only on
`.board-list.mobile-view`, the class the template sets from Utils.isMiniScreen().
The phone MEDIA QUERY builds a grid too, and a narrow window that is not a mini
screen - a desktop browser under 800px, a tablet in desktop mode - took that path
and never got it. It belongs on the rule that creates the grid, and is on both.

THE SPACING. The tiles had an 8px grid gap PLUS a `margin-bottom: 0.5rem` each,
so they were 16px apart vertically and 8px horizontally - which is what reads as
"too close to each other" sideways. One value now, 10px, in both directions, with
the margin removed on both the media-query and the mini-screen paths.

The mobile full-screen popup had the same class of bug as the first: `height:
100vh` on a `position: fixed` box (the viewport with the toolbars HIDDEN, so it
reaches under them) and a `.content-wrapper` of `calc(100vh - 48px)`, guessing at
its own title bar. It states dvh now, and the wrapper is left to the flex column
it is already part of.

Desktop is unaffected: above 800px none of these rules apply and the page scrolls
in #content as it always has.

tests/allBoardsPhoneScrollAndTiles.test.cjs pins all four, end to end - including
that no step between the body and the board list computes a height from the
viewport. tests/mobileViewportHeight.test.cjs REQUIRED the calc that was the bug,
so that assertion is replaced with its opposite and says why.

Thanks to mimZD and xet7 !

Fixes #6488,
2026-08-11 02:11:06 +03:00
Lauri Ojansivu 01f691ba3d Build menus: the chart-index report is a command a person runs.
build.sh and build.bat list every releases/*.sh that is not a workflow-only
helper, and backfill-charts.sh is not one of those - it is read by hand, on a
machine with a charts checkout. It is a Release menu entry in both, and
`./build.sh backfill-charts` / `build.bat backfill-charts` on the command line.

Thanks to xet7 !
2026-08-11 02:01:55 +03:00
Lauri Ojansivu 8248892e33 Charts: report the releases the index is missing, and repair it.
The charts job is fixed to RUN (previous commit); this is about what it left
behind. A chart entry is written once, by release-charts.sh, during the release
it belongs to - so a release whose charts job did not run leaves a hole that
nothing ever fills, because the next release adds ITS entry and never looks back.
v10.80 is one of those. Counted against the live index: 216 of the 690 WeKan
releases have a chart entry, and 474 do not.

releases/backfill-charts.sh answers "which WeKan releases should the index
list?" with: the ones that exist AND can be installed.

  keep   216  a chart is already published; never rebuilt, because repackaging
              changes a digest helm clients have already seen
  build  369  the release exists, its container image exists, and no chart is
              published - the hole to fill
  omit   162  the release exists but has NO container image on ghcr. A chart is
              a pointer to an image (tag: v<version>), so an entry for one of
              these is an install that fails at the pull. They stay OUT of the
              index rather than being listed and broken - most are releases whose
              own docker job failed, which is why they have no chart either.

The index is rebuilt with `helm repo index --merge`, from the packages actually
in the directory, so "omitted" needs no bookkeeping: a version with no .tgz gets
no entry, and no entry can point at a package that is not there. A ghcr request
that fails is "unknown", not "no image" - reading a network hiccup as absence
would omit a release that has one.

The run also found something already wrong. The published index has FOUR entries
for 9.36.0 and two for 10.30.0, every copy with a DIFFERENT digest and the SAME
url, because release-charts.sh PREPENDS an entry each time it runs and a re-run
duplicated the version instead of replacing it. At most one of those digests can
be the digest of the wekan-9.36.0.tgz that is really served; a helm client that
picks another fails the integrity check on a perfectly good file. So the rebuild
de-duplicates: for a version with more than one entry, keep the one whose digest
MATCHES the package on disk, and only when there is no package to compare
against fall back to the newest.

Nothing is written without --apply and nothing is pushed without --push, and the
charts job runs it in plan mode only, into the run summary - so the gap is
visible in the release it appears in, while rewriting a published Helm index
stays a command the maintainer runs deliberately.

tests/backfillCharts.test.cjs runs the dedup rule for real against a synthesised
index, including the case that separates the rule from its fallback: the matching
digest on the OLDEST copy, which must still win.

Thanks to xet7 !
2026-08-11 02:00:37 +03:00
Lauri Ojansivu 7b8c377099 Release: the retired Mac runner, the jobs a cancel skipped, and the armv6 userland.
Three failures from the v10.80 run, which reported four snap arches red, one
bundle red, and three jobs that never ran at all.

build-mac-x64 asked for macos-13, and GitHub has RETIRED that image:
actions/runner-images publishes macos-14, macos-15 and macos-26 only, and
macos-13 is gone from the hosted-runner table. A retired label does not fail -
that is the whole problem. The job QUEUES, waiting for a runner that is never
coming. It sat there for nearly two hours and had to be cancelled by hand, and
this has been happening every release: NO WeKan release has ever carried a
wekan-<version>-mac-x64.zip. Intel macOS did not go away, it was renamed, and
.tools/TSC already builds its Intel Mac on the new label: macos-15-intel.

Cancelling it is what skipped charts, ucs and nextcloud. A cancelled job cancels
the RUN, and a cancelling run skips every job that has not started yet - so all
three were skipped in the same second the docker job they wait on succeeded,
after the tag, the release, every bundle and the multi-arch image had shipped.
The Helm chart for a published WeKan was never pushed and nothing said why. A job
whose if is always() still runs while a run is cancelling, so the three now say
always() && <each need>.result == 'success': explicit about the one condition
that ever mattered, instead of relying on a default a cancellation overrides.

The armv6 bundle died on

    qemu-arm: Could not open '/lib/ld-linux-armhf.so.3': No such file or directory

400 lines in, after the whole apt install. DEBIAN HAS NO ARMv6 PORT.
debian:trixie publishes arm/v5 and arm/v7 and nothing between them, and because
containerd treats a lower ARM variant as compatible, --platform linux/arm/v6
quietly resolved to arm/v5: Debian armel, ARMv5 SOFT-float, which contains no
hard-float loader at all. node-armv6 is hard-float, so it could not start.

Two things were wrong. The preflight matched on "architecture": "arm" and ignored
the VARIANT, so "debian:trixie has no linux/arm/v6" was never the answer it gave;
it compares architecture and variant now, and an unrequested variant still
matches anything so linux/arm64 (recorded as arm64/v8) keeps working. And armv6
is built in Debian's arm/v7 container instead, which IS armhf - the same
hard-float ABI and the same loader, so node-armv6 runs in it.

Nothing ARMv7 reaches the bundle. The container's npm install compiles nothing:
it is npm-rebuild over pure-JavaScript packages, with no node-gyp and no CXX in
the log. node-armv6, ferretdb-armv6 and the armv6 MongoDB tools are all
downloaded already built for ARMv6. The single exception is the bundled qemu-user
(#6458), which is copied OUT of the container, and an ARMv7 qemu-armv7l in an
ARMv6 bundle is a binary a Raspberry Pi 1 cannot execute - under a name its
cpu-exec, which looks for qemu-armv6l, would never ask for. So the matrix marks
armv6 same_arch_container: false and install-node-for-arch.sh ships without one,
letting cpu-exec fall back, which is what it is written to do.

Three guards, because each of these was invisible until a release ran:
tests/workflowRunnerLabels.test.cjs checks every runs-on in every workflow
against the labels GitHub currently offers and names the replacement for a
retired one; tests/releaseCancelSafeJobs.test.cjs pins the always() guards and
that they still require every need to have SUCCEEDED;
tests/releaseArmv6Container.test.cjs pins the armv6 container and runs the
preflight's own platform match against the manifest shapes it meets, including
that debian:trixie answers NO to linux/arm/v6.

Thanks to xet7 !
2026-08-11 01:52:41 +03:00
Lauri Ojansivu e1c6221f22 FerretDB: connect with directConnection=true, so a fresh docker compose up starts.
A new install with nothing changed but the port and ROOT_URL could not reach the
database at all:

    MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
    reason: TopologyDescription { type: 'ReplicaSetNoPrimary',
            servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
            setName: 'rs0', ... }

0.0.0.0 is in no compose file. It is FerretDB's own listen address, and the
driver was handed it by the server.

The ferretdb service runs with --repl-set-name=rs0, added in #6480/#6481 so
Meteor can tail an OpLog instead of poll-and-diff. FerretDB therefore answers the
hello handshake as a one-member replica set, and fills hosts, me and primary with
its --listen-addr, which is the wildcard 0.0.0.0:27017. A driver that is not in
direct-connection mode reads that reply as an invitation to do replica-set
DISCOVERY: it adopts the advertised member list and drops the seed it was given,
because the server reports a name other than the one that was dialled. So
mongodb://ferretdb:27017 became 0.0.0.0:27017, which inside the wekan container is
the wekan container itself, where nothing listens.

Measured against FerretDB v1.49.0 - the version reported - with the mongodb
driver the bundle ships:

    mongodb://localhost:27017/wekan
      -> ReplicaSetWithPrimary, servers: 0.0.0.0:27017   (the seed discarded)
    mongodb://localhost:27017/wekan?directConnection=true
      -> Single, servers: localhost:27017

directConnection=true keeps the driver on exactly the host it was given. It costs
nothing else: the handshake still reports setName rs0, which is the only thing
Meteor checks before it will tail an OpLog (accounts for the check in the mongo
package's oplog_replica_set_error.js), so #6480/#6481 are unaffected and the
commented MONGO_OPLOG_URL examples keep replicaSet=rs0 beside it.

All five FerretDB v1 compose files are fixed, being the ones that start FerretDB
with --repl-set-name. The MongoDB compose files are deliberately left alone: those
are real replica sets whose members are reachable under the names they advertise,
and directConnection would pin Meteor to a single node of one.

tests/ferretdbDirectConnection.test.cjs pins both halves - that a compose file
running FerretDB as a replica set carries directConnection=true on every URL
pointing at it, and that the MongoDB ones do not.

Thanks to Dandrass and xet7 !

Fixes #6582,
2026-08-11 01:51:51 +03:00
93 changed files with 7395 additions and 241 deletions

No files matched your search

+106 -2
View File
@@ -10,7 +10,13 @@ name: Release all missing
# those six without running the whole thing again, version bump and all.
#
# This finishes a release that already exists. It never bumps a version, never
# tags, never touches Docker, snap, the website or the charts.
# tags, and never touches Docker, snap or the website.
#
# It DOES publish the Helm chart, because that is a thing a release can be
# missing like any other: the chart entry is written once, during the release it
# belongs to, so a release whose charts job did not run leaves a hole that the
# next release never goes back for - v10.80 is one of those, and it was not
# alone. See the charts job at the end.
#
# WHAT IT CAN BUILD, and why that is not everything. Only build-amd64 runs
# Meteor; every other bundle WeKan ships is that same bundle repacked. This job
@@ -279,9 +285,107 @@ jobs:
publish: true
only: ${{ needs.plan.outputs.flatpak }}
# ─── The Helm chart, for a release that never got one ───────────────────────
#
# Everything else here rebuilds a missing BUNDLE; this publishes a missing
# CHART, which goes missing the same way and for the same reason. A chart is
# written once, by release-charts.sh, during the release it belongs to, so a
# release whose charts job did not run has no chart and nothing ever goes back
# for it. That is not rare - 146 of the packages already on gh-pages had no
# index entry, and v10.80 has no package at all, because a cancelled
# build-mac-x64 cancelled its run before the charts job started.
#
# It is the one part of this workflow that PUSHES to another repository, so it
# is guarded twice: it does nothing unless a token that can push to
# wekan/charts is configured, and nothing if the chart for this version is
# already published. Re-publishing an existing version would re-tar the package
# and change the digest of a chart people have already pulled.
charts:
name: Helm chart
needs: plan
if: ${{ always() && needs.plan.outputs.version != '' }}
runs-on: ubuntu-latest
steps:
- name: Is a chart already published for this version?
id: check
env:
V: ${{ needs.plan.outputs.version }}
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
run: |
set -uo pipefail
if [ -z "${REPO_TOKEN:-}" ]; then
echo "::warning::No WEKAN_REPO_TOKEN, so the Helm chart cannot be published. Add a token that can push to wekan/charts to have this finish the chart too."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# The published index is the authority on what exists, not this
# checkout. An entry's own version is at EXACTLY four spaces; the
# mongodb subchart's version sits deeper and would match a loose grep.
curl -fsSL --retry 3 -o index.yaml https://wekan.github.io/charts/index.yaml || {
echo "::warning::Could not read the published chart index; not touching the charts repo."
echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
# -F, a FIXED string: the dots in a version are regex wildcards
# otherwise, so "10.80" would also match a line reading 10x80. -x so it
# is the whole line, and the four-space indent is what makes it an
# entry's own version rather than the mongodb subchart's, which sits
# deeper.
if grep -qxF " version: ${V}.0" index.yaml; then
echo "::notice::v$V already has a published Helm chart (${V}.0); nothing to do."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "v$V has no published chart; publishing ${V}.0."
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout wekan (for releases/release-charts.sh)
if: steps.check.outputs.skip != 'true'
uses: actions/checkout@v7
with:
path: wekan
- name: Checkout wekan/charts (main + gh-pages)
if: steps.check.outputs.skip != 'true'
uses: actions/checkout@v7
with:
repository: wekan/charts
token: ${{ secrets.WEKAN_REPO_TOKEN }}
fetch-depth: 0
path: charts
- name: Install Helm
if: steps.check.outputs.skip != 'true'
uses: azure/setup-helm@v5
- name: Publish the chart for this release
if: steps.check.outputs.skip != 'true'
env:
CHARTS_DIR: ${{ github.workspace }}/charts
run: |
set -euo pipefail
cd charts
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin '+refs/heads/*:refs/remotes/origin/*'
git branch --track gh-pages origin/gh-pages 2>/dev/null || true
git checkout main
cd ..
./wekan/releases/release-charts.sh "${{ needs.plan.outputs.version }}"
- name: Job result
if: always()
run: |
case "${{ job.status }}" in
success) mark='OK ' ;;
failure) mark='FAILED ' ;;
cancelled) mark='CANCELLED' ;;
*) mark='${{ job.status }}' ;;
esac
echo "$mark charts" >> "$GITHUB_STEP_SUMMARY"
done:
name: what is still missing
needs: [plan, extra-arches, appimage, flatpak]
needs: [plan, extra-arches, appimage, flatpak, charts]
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
+90 -11
View File
@@ -410,6 +410,17 @@ jobs:
# is actually on the registries. If `docker` fails, this job is skipped.
charts:
needs: docker
# `always()` so that a CANCELLATION somewhere else in the run cannot skip
# this job. It is not defensive dressing: in v10.80 the release itself, the
# image and every bundle were published, and then charts, ucs and nextcloud
# were all skipped the moment `docker` finished - because build-mac-x64 was
# queued on a retired runner label and had to be cancelled by hand, and a
# cancelled job cancels the RUN, which skips every job that has not started.
# A job whose `if` is `always()` still runs while a run is cancelling, so the
# chart is published for a release that IS on the registries. The guard is
# then explicit about the only condition that ever mattered: docker SUCCEEDED
# - the chart points at the image that job pushes, so it must exist first.
if: ${{ always() && needs.docker.result == 'success' }}
runs-on: ubuntu-latest
steps:
# First step on purpose: a missing secret is then ONE named line at the top of
@@ -475,6 +486,21 @@ jobs:
run: |
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
# What the index does NOT have, reported every release rather than
# discovered years later. A chart entry is written once, during the release
# it belongs to, so a release whose charts job did not run leaves a hole
# that nothing ever fills - v10.80's, when a cancelled build-mac-x64
# cancelled the run, is one of many. This is a PLAN ONLY: it reads the
# releases API, the live index and ghcr, writes nothing, and prints which
# releases have a chart, which are missing one that could be built, and
# which are omitted because they have no container image for a chart to
# point at. Filling them in is `backfill-charts.sh --apply --push`, run by
# hand: rewriting a published Helm index is not a side effect of a release.
- name: Report which releases the chart index is missing
continue-on-error: true
run: |
./wekan/releases/backfill-charts.sh | tee -a "$GITHUB_STEP_SUMMARY"
# Always, as the last step: the run summary then answers "what worked and what
# did not" for the whole release at a glance, instead of eighteen job logs. A
# step that failed has already printed an ::error:: saying why; this names the
@@ -2314,7 +2340,18 @@ jobs:
# failing red, and returns on its own if a Node.js for it appears again.
build-mac-x64:
needs: [prepare, build-amd64, release]
runs-on: macos-13 # macos-13 runners are Intel (x86_64); macos-14+ are arm64
# Intel (x86_64) macOS. It was `macos-13`, and GitHub RETIRED that image -
# actions/runner-images publishes macos-14, macos-15 and macos-26 only, and
# macos-13 is gone from the hosted-runner table. A retired label does not
# fail: the job QUEUES FOREVER waiting for a runner that will never come, so
# every release since then had a build-mac-x64 sitting there for hours until
# it was cancelled by hand - and cancelling it cancelled the RUN, which is
# what skipped the charts, ucs and nextcloud jobs (they had not started yet).
# No release has ever carried a wekan-<version>-mac-x64.zip because of it.
# Intel macOS is still offered, under new labels: macos-15-intel and
# macos-26-intel (macos-14/15/26 without the suffix are Apple Silicon). This
# is the same label .tools/TSC already builds its Intel Mac on.
runs-on: macos-15-intel
timeout-minutes: 60
env:
VERSION: ${{ needs.prepare.outputs.version }}
@@ -2622,17 +2659,37 @@ jobs:
node_arch: x86
ferretdb_arch: i386
optional: true
# ARMv6: Raspberry Pi 1 and Zero. It runs in a linux/arm/v6 container,
# takes wekan/node-patches' node-armv6 (nobody else publishes an ARMv6
# Node - nodejs.org dropped it after Node 11), and FerretDB and the
# MongoDB tools build a GOARM=6 armv6 of their own. Their armel is
# GOARM=5 and WOULD run on these boards, but in software floating
# point, so it is not used here.
# ARMv6: Raspberry Pi 1 and Zero. It takes wekan/node-patches'
# node-armv6 (nobody else publishes an ARMv6 Node - nodejs.org dropped
# it after Node 11), and FerretDB and the MongoDB tools build a GOARM=6
# armv6 of their own. Their armel is GOARM=5 and WOULD run on these
# boards, but in software floating point, so it is not used here.
#
# The CONTAINER is linux/arm/v7, not linux/arm/v6, and that is not a
# typo. DEBIAN HAS NO ARMv6 PORT: debian:trixie publishes arm/v5 and
# arm/v7 only, and because containerd treats a lower ARM variant as
# compatible, asking it for linux/arm/v6 silently handed back arm/v5 -
# Debian armel, ARMv5 SOFT-float. node-armv6 is HARD-float, so it could
# not start there and the job died on
# qemu-arm: Could not open '/lib/ld-linux-armhf.so.3'
# after the whole apt install (v10.80). Debian's arm/v7 IS armhf: the
# same hard-float ABI and the same loader, so node-armv6 runs in it.
#
# Nothing ARMv7 reaches the bundle. What the container does is run
# `npm install`, and that install compiles NOTHING - it is npm-rebuild
# over pure-JavaScript packages ("rebuilt dependencies successfully",
# no node-gyp, no CXX). Every binary in the bundle is downloaded already
# built FOR ARMv6: node-armv6, ferretdb-armv6 and the armv6 MongoDB
# tools. The container only has to be a userland this arch's node can
# RUN in, and armhf is exactly that. The one thing that would have been
# the container's own CPU is the bundled qemu-user, and that is why
# SAME_ARCH_CONTAINER is false below.
- arch: armv6
platform: linux/arm/v6
platform: linux/arm/v7
image: debian:trixie
node_arch: armv6
ferretdb_arch: armv6
same_arch_container: false
optional: true
- arch: armhf
platform: linux/arm/v7
@@ -2778,6 +2835,7 @@ jobs:
-e NODE_SHA256="$NODE_SHA256" \
-e ARCH='${{ matrix.arch }}' \
-e NODE_ARCH='${{ matrix.node_arch }}' \
-e SAME_ARCH_CONTAINER='${{ matrix.same_arch_container }}' \
-v "$(pwd)/bundle:/bundle" \
-v "$(pwd)/releases:/releases:ro" \
-w /bundle/programs/server \
@@ -3988,7 +4046,18 @@ jobs:
# usually transient, so the three attempts above cover a one-off.
# (The large-history push that also broke these builds is fixed
# separately, by flattening the repo before the push.)
echo "::error::If the build ended as 'Stopped' with no artifact: the Launchpad project https://launchpad.net/xet7-craft-remote-build should be MIT / X/Expat (it is set now) - re-set it only if it shows 'None specified'. Otherwise Launchpad cancelled the build as it ran (often an OOM in the Meteor npm install, or a transient build-farm reset - the printed build log ends mid-step); re-run, and if it persists on this arch open a Launchpad question. Builds: https://launchpad.net/~xet7/+snaps" >&2
# THE BUILD LOG IS PRINTED ABOVE AND IT SAYS WHY. Read it before
# believing any guess in this message - the guess has been wrong. For
# four releases this line said "often an OOM in the Meteor npm install,
# or a transient build-farm reset", and every one of the four
# architectures was in fact failing on one deterministic line:
# Failed to copy '.../parts/mongo42/install/mongo42':
# no such file or directory
# a `stage:` filter naming a path that an amd64/arm64-only part does not
# produce anywhere else. Nothing transient about it, and "re-run" was
# the one thing that could not help. So these are named as the things to
# LOOK FOR in the log above, not as what probably happened.
echo "::error::snap-launchpad ${{ matrix.arch }}: the Launchpad build log is printed above - read it first; it names the part and the line that failed. Common causes, in the order they are worth checking: (1) a 'Failed to copy .../parts/<part>/install/<path>' - a stage:/prime: filter naming something the part did not produce on THIS architecture, which is deterministic and will not pass on a re-run; (2) an npm/network error inside a part's override-build, which is transient and is retried; (3) 'Stopped' with no artifact and no error - then check that https://launchpad.net/xet7-craft-remote-build still has a licence set (MIT / X/Expat), and otherwise Launchpad ended the build itself. Builds: https://launchpad.net/~xet7/+snaps" >&2
# A wrong or expired LP_CREDENTIALS makes Launchpad answer 401/403, which
# reads as an ordinary build failure unless the log is checked. But the
# pattern must be SPECIFIC: a bare `401`/`403` matches any three digits in
@@ -4361,8 +4430,13 @@ jobs:
ucs:
# ENABLED. `continue-on-error: true` below means a failure here can never
# fail the release or hold up another job - the point of running it is to
# SEE what it reports. Disable again with `if: ${{ false }}`.
# SEE what it reports. Disable again by replacing the `if` below with
# `if: ${{ false }}`.
needs: [prepare, release, docker]
# always(), for the same reason as the charts job above: a cancellation
# elsewhere in the run skips every job that has not started yet, and in
# v10.80 that silently dropped this one along with charts.
if: ${{ always() && needs.prepare.result == 'success' && needs.release.result == 'success' && needs.docker.result == 'success' }}
continue-on-error: true
timeout-minutes: 45
runs-on: ubuntu-latest
@@ -4456,8 +4530,13 @@ jobs:
nextcloud:
# ENABLED. `continue-on-error: true` below means a failure here can never
# fail the release or hold up another job - the point of running it is to
# SEE what it reports. Disable again with `if: ${{ false }}`.
# SEE what it reports. Disable again by replacing the `if` below with
# `if: ${{ false }}`.
needs: [prepare, release, docker]
# always(), for the same reason as the charts job above: a cancellation
# elsewhere in the run skips every job that has not started yet, and in
# v10.80 that silently dropped this one along with charts.
if: ${{ always() && needs.prepare.result == 'success' && needs.release.result == 'success' && needs.docker.result == 'success' }}
continue-on-error: true
timeout-minutes: 60
runs-on: ubuntu-latest
+887 -3
View File
@@ -276,6 +276,890 @@ browser build to verify).
</details>
# v10.83 2026-08-11 WeKan ® release
**In short:** a **CRITICAL SECURITY ISSUE**, **PassBleed**: the single-card
Excel export authorised against the board named in the URL and then read the
card named in the URL, with nothing tying the two together. Any authenticated
user could create their own public board, name it as the board, and export any
card from any private board on the instance - including the bytes of its image
attachments. The identically shaped PDF route had always resolved its card
correctly, which is what showed this was an omission rather than a decision, and
it is what the Excel exporter now does. It also fixes **broken avatar images**,
seen after upgrading from v6 but never actually working: the route that serves
them asked `Meteor.userId()`, which throws in a plain HTTP handler rather than
answering "nobody", and the handler turned that into a 500 - and, once that was
fixed, that the same route had always ignored the `boardId` the client appends
so a **public board** can show its members' pictures to visitors. The binaries
below are v10.82's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-amd64) | v1.48.0 | `2737687fd29a8a761cd960e45f300b68cf7b4a87d50c4cc5280bcbd42b6aa163` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-arm64) | v1.48.0 | `5ae705dd49515a4ecd4e295c3b9aa4f3b454fad78613ec60fb99316bd7c34e3f` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-loong64) | v1.48.0 | `06ec86263455a7b598d22a87df0e044ea73ab5a3b72e96ad12ebed03c1374ac2` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-arm64) | v1.48.0 | `9b15f4c10e473cd0a2c4feb4cb43e18042bd60c7035ec66cab3cfbe13edaabab` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-amd64) | v1.48.0 | `4e188246dfa33bccef4cdd86701bc498b037cb3e91f579ff0dccb93aa0ef03ad` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-ppc64le) | v1.48.0 | `0400cd6dfc3d10d987a0fe80d75baa86c03c19170770fa2e602c92d558c3cfa6` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-riscv64) | v1.48.0 | `d37c35af988670b9ed182b8c5966c06a06362f6c6ace6aebd93ccdfa32c9a26b` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-s390x) | v1.48.0 | `6c7d61fbb8c79b2e8733be8f71910f710e8c5cd25208c451bdc513c8313b0340` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-win64.exe) | v1.48.0 | `ea57e1bcd153b51d2065ab01515b21ec05d8f615444c15603ab8158b8a661dd2` |
This release fixes the following CRITICAL SECURITY ISSUE of
[PassBleed](https://wekan.fi/hall-of-fame/passbleed/):
**The single-card Excel export** - which card it is allowed to read.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5269d0ba5">PassBleed: the export authorised against one board and read a card from another</a>. Thanks to TWPaMWang and xet7.</summary>
[PassBleed](https://wekan.fi/hall-of-fame/passbleed/) -
[GHSA-6p5m-f9p2-wqm5](https://github.com/wekan/wekan/security/advisories/GHSA-6p5m-f9p2-wqm5),
Moderate, CWE-639, CVSS 6.5. `GET
/api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel` checked whether
the caller could see the board in `:boardId`, then resolved the card by
`:cardId` alone. Nothing confirmed that the card was on that board, so the two
identifiers came apart: one decided the authorisation, the other decided the
data.
The pass is self-service. `POST /api/boards` takes `permission` straight from
the request body, so any authenticated user could mint their own PUBLIC board,
name it as `:boardId`, and pass the id of a card in somebody else's private
board as `:cardId`. `:listId` was never used in a query at all and could be any
string.
What came back was the card: title, full description, members and assignees,
every comment with its author, checklists and checklist items, subtask titles,
attachment metadata - and, because image attachments are read through
`getReadStream()` and embedded with `workbook.addImage`, the attachment BYTES.
The same board could be reused while `:cardId` was substituted, which made it a
scriptable bulk read rather than a single disclosure. The REST API is on by
default in the shipped Docker configuration.
The fix was already in the codebase one file away: the identically shaped PDF
route has always resolved `getCard({ _id, boardId, listId })` and 404s a
cross-board id. That control is what shows the Excel exporter's omission was a
defect rather than a decision, and it is what the Excel exporter now does.
Constraining the QUERY matters more than a check after it - the exporter fans
out on the same card id for checklists, subtasks, comments and attachments, none
of which carry a board constraint of their own, so a card that cannot resolve
outside the authorised board makes all of them safe by construction.
The route binds the two identifiers as well, before either branch builds -
deliberate duplication, because that is where both arrive together and it covers
the public-board branch, which skips authentication entirely. A card that is not
on the named board is a 404 rather than a 403, so the difference does not reveal
whether a card id exists.
</details>
and fixes the following bugs:
**Avatars** - the routes that serve a profile picture, and who they serve it to.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/bf047d53c">Ask the request who it is, because Meteor.userId() cannot</a>. Thanks to markusst1982 and xet7.</summary>
Following the same upgrade as [#6583](https://github.com/wekan/wekan/issues/6583),
profile pictures came back as broken images - initials rendered fine, and the
Admin Panel showed a user's picture while a board showed the missing-picture
icon for the same person.
Nothing was lost, and the migration is not at fault. The avatar files migrate,
and the Meteor-Files record made from a CollectionFS filerecord even reuses its
`_id`, so a 6.x URL still names the right object. What broke is the request for
it. A 6.x install stores `profile.avatarUrl` as `/cfs/files/avatars/<id>`; that
route serves the legacy bytes if they are still there and otherwise redirects to
`/cdn/storage/avatars/<id>`, which asked who was asking with `Meteor.userId()`.
That reads the current DDP invocation's environment, which exists inside a
method or a publication and NOT in a WebApp handler - where it does not return
"nobody", it THROWS. The handler wraps its body in a `try`/`catch` that answers
500, so the throw was swallowed into a broken image, and no avatar served
through that route ever reached anybody on any install. The upgrade did not
cause it; it moved every avatar URL onto the route where it already applied.
`server/routes/legacyAttachments.js` had the identical call, so legacy
attachment URLs failed the same way.
An HTTP request carries its identity in the request: a bearer token, an
`X-Auth-Token` header, an `?authToken=` parameter, or the login cookie - and on
Sandstorm, a platform-injected user id and no Meteor token at all.
`server/routes/universalFileServer.js` has always resolved it that way and
serves attachments correctly today. `server/lib/requestUser.js` lifts that
resolution out so the two routes that were guessing share it rather than grow a
third copy. It never throws: a caller deciding whether to serve a file wants an
answer, not an exception its own `catch` will turn back into a 500.
`tests/requestUserAuth.test.cjs` pins that neither route calls
`Meteor.userId()`, that both `await` the resolver - an unawaited Promise is
truthy and would authorise everybody - that all four token carriers and the
Sandstorm path are handled, and that the migration still reuses the id the old
URL names. Confirming the served image needs an upgraded instance.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/484eae736">Honour the boardId parameter the client has been sending all along</a>. Thanks to markusst1982 and xet7.</summary>
Found while checking why the Admin Panel showed a picture that a board did not.
The two URLs differ in one thing: the `avatarUrl` helper in
`client/components/users/userAvatar.js` appends `?boardId=<id>`, and says why in
its own comment - *"so public viewers can access avatars on public boards"*. The
Admin Panel uses `profile.avatarUrl` raw.
`/cdn/storage/avatars/:fileName` never read that parameter. It required a
signed-in user and nothing else, so on a public board every visitor who was not
logged in got a 401 and the missing-picture icon - the exact case the parameter
was added for. Fixing `Meteor.userId()` alone would have left that half broken.
The named board must now exist, be public, AND have the avatar's owner as a
member. The last part is not ceremony: without it, naming any public board would
unlock any avatar on the instance, and a public board publishes its own members,
not everybody.
The legacy redirect keeps the query string too. `/cfs/files/avatars/<id>` 301s
to `/cdn/storage/avatars/<id>`, and that is the path EVERY migrated 6.x avatar
URL takes, so dropping `?boardId=` there would 401 exactly the installs the
entry above sets out to fix.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.82 2026-08-11 WeKan ® release
**In short:** a **CRITICAL SECURITY ISSUE**, **WhereBleed**: eight Admin Panel
handlers took a query selector from the client and checked only its type, so a
`$where` in one made the database run the caller's JavaScript - a repeatable
denial of service, reachable by a per-tenant admin. The detector for it was
already in the codebase and wired into one publication; the eight siblings never
called it, and now share the one copy. Then **the snap**, where two permanent
markers meant an instance that had failed on an older revision never retried on
the fixed one, so the MongoDB 4.2 reader added for it never ran.
**Notifications** grew an unbounded array inside the user document that SQLite
was rewriting on every addition, which is the slow login and the pinned CPU.
**Clicking an open card** closes it again, and a focused Admin Panel checkbox is
no longer drawn as a diamond. It also adds the first new feature in this
release: **checklists and card feature groups fold away**, on the opened card
and on the minicard, asked for since 2018. Below that: a typed two-digit year
refused rather than stored as the year 26, the **Helm chart index** listing only
charts that can be installed, and a way to remove the Templates containers made
for accounts that never used them. And two developer-facing fixes: the
database-conformance stage no longer opens a debug port nothing in it uses - one
taken by an unrelated FerretDB made every backend report a database problem that
was not one - and two more snap give-up paths that deleted the directory their
own stage filter names. The binaries below are v10.81's: nothing here rebuilds
them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-amd64) | v1.48.0 | `2737687fd29a8a761cd960e45f300b68cf7b4a87d50c4cc5280bcbd42b6aa163` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-arm64) | v1.48.0 | `5ae705dd49515a4ecd4e295c3b9aa4f3b454fad78613ec60fb99316bd7c34e3f` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-loong64) | v1.48.0 | `06ec86263455a7b598d22a87df0e044ea73ab5a3b72e96ad12ebed03c1374ac2` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-arm64) | v1.48.0 | `9b15f4c10e473cd0a2c4feb4cb43e18042bd60c7035ec66cab3cfbe13edaabab` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-amd64) | v1.48.0 | `4e188246dfa33bccef4cdd86701bc498b037cb3e91f579ff0dccb93aa0ef03ad` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-ppc64le) | v1.48.0 | `0400cd6dfc3d10d987a0fe80d75baa86c03c19170770fa2e602c92d558c3cfa6` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-riscv64) | v1.48.0 | `d37c35af988670b9ed182b8c5966c06a06362f6c6ace6aebd93ccdfa32c9a26b` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-s390x) | v1.48.0 | `6c7d61fbb8c79b2e8733be8f71910f710e8c5cd25208c451bdc513c8313b0340` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-win64.exe) | v1.48.0 | `ea57e1bcd153b51d2065ab01515b21ec05d8f615444c15603ab8158b8a661dd2` |
This release fixes the following CRITICAL SECURITY ISSUE of
[WhereBleed](https://wekan.fi/hall-of-fame/wherebleed/):
**The Admin Panel's People, Org, Team and Translation panes** - what a query
from the client is allowed to be.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b4ebe48d7">WhereBleed: eight Admin Panel handlers ran the caller's selector unchecked</a>. Thanks to TungNGo02 and xet7.</summary>
[WhereBleed](https://wekan.fi/hall-of-fame/wherebleed/) -
[GHSA-phm4-4v26-j2vq](https://github.com/wekan/wekan/security/advisories/GHSA-phm4-4v26-j2vq),
Moderate, CWE-943, CVSS 5.8. The people, org, team and translation publications
and their companion count/page methods take a query selector from the client and
validate only its TYPE - `check(query, Match.OneOf(Object, null))` - which is
not validation, because a MongoDB selector is executable data. `$where` makes
the database run the caller's JavaScript once per document scanned, so
`Meteor.subscribe('team', { $where: 'while(true){}' }, 25, 0)` pins a database
worker for as long as the caller likes, repeatably: denial of service for every
tenant on the instance from one narrowly-scoped account.
The reporter demonstrated both halves on v10.81 against a real MongoDB 7:
`$where: 'sleep(2000) || true'` made the subscription take 2.03s and return the
document, `$where: 'false'` returned nothing in 0.00s - the caller deciding, in
JavaScript, which documents come back.
It needs an authenticated admin session, so no board member or visitor can reach
it. It matters at this severity because the people and org surfaces are open to
a **per-tenant Global Admin**, a role meant to be confined to one Organization,
and those two wrap the caller's selector as `{ $and: [query, restriction] }`
rather than stripping execution operators out of it - so merging the tenant
restriction never removed the `$where`, and a role scoped to one tenant reached
instance-wide impact.
What makes this one particular is that **the defence was already in the
codebase**. `classifySelector` and `hasWhere` were written for exactly this
class, are unit-tested, and were wired into the card-window publication. Eight
sibling handlers taking the identical shape of selector simply never called
them. So the fix adds no new detection logic: that publication's own helper
moves unchanged into a shared module and all nine call sites use the one copy -
a second copy would be the same bug set up to happen again.
Each handler refuses with the "match nothing" selector the card window already
uses in production, `{ _id: { $in: [] } }`, so a refused request returns an
empty result instead of throwing at an admin mid-page. Ordinary behaviour is
untouched: none of the searches, filters, regexes,
`$or`/`$and`/`$in`/`$elemMatch` or date ranges those panes send carries an
execution operator.
FerretDB, WeKan's default database, rejects `$where` itself, so this degrades to
a rejected query there; the supported MongoDB path is where it was reproduced.
MongoDB 7 also happens to reject `$where` inside the aggregation pipeline the
count methods use - but that is an engine accident for one operator on one call
path, so those methods are guarded like the rest rather than left to it.
</details>
and adds the following new feature:
**Cards** - folding away what you are not reading.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1cef5ede9">Checklists and card feature groups collapse, on the opened card and on the minicard</a>. Thanks to czinkos, MikeRatcliffe, JannetGen and xet7.</summary>
Asked for in 2018: "It would be great to have collapsable checklists on cards",
and again this week - "Long checklists can make a card pretty cluttered, so
being able to collapse them and expand only when needed would keep the board
much cleaner."
WeKan had something adjacent and it was not this. A checklist carries
`hideAllChecklistItems`, reachable through a toggle switch inside the checklist
actions popup - but that is a field ON THE CHECKLIST, so flipping it changes
what everyone on the board sees, and it is an edit to the card rather than a
view preference. It is untouched; it has its own uses.
Collapsing is per-user, which WeKan already says twice in its own models for
lists and swimlanes, so this follows them: one map in the user profile keyed by
card. A feature group uses its own section name, an individual checklist uses a
key of its own - which is why folding a checklist on the opened card folds it on
the minicard too.
The control is a caret on the title rather than another entry in a menu, since
the point is to fold at a glance while reading the card. It carries
aria-expanded and answers Enter and Space. For the sixteen feature groups on the
opened card it is done once, with a delegated handler and CSS: they all open
with a title but only three wrap what follows in a content element, so folding
hides every sibling after the title, which works whatever a section puts there.
A checklist's progress bar stays visible when folded - it is the summary of what
was folded away - and the fold survives reopening the card.
</details>
and fixes the following bugs:
**The snap, upgrading from an old MongoDB** - and why a fixed version changed
nothing.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/097ad9160">A new snap revision is a new chance, so the MongoDB 4.2 reader actually gets to run</a>. Thanks to Philippe-Bentegeac, JDeepix, imlit and xet7.</summary>
Reported against 10.81: "I still have the exact same issue, MongoDB cannot
start. The web interface is still unreachable, and I do not see the messages you
added in the last commits." The messages were missing because the code that
prints them never ran.
Two markers in `$SNAP_COMMON` stop the snap doing work, and both were PERMANENT.
`.mongodb-data-too-old` is written when no reader in the snap could open the
data, after which mongod is not started at all; `.mongod-start-failures` is a
counter that, past three, stops the migration being re-run so a migration/mongod
restart loop cannot form. Both are right, and both record a conclusion about
what THAT snap could do.
An instance that had already failed on 10.79 or 10.80 - before mongod 4.2 was
bundled - carried a marker saying "no reader can open this" and a counter far
past three. So 10.81 never started mongod, never attempted the migration, and
printed nothing new. Upgrading to the version with the fix changed nothing,
which is exactly what their log shows: the database-selection line, then
"Waiting for MongoDB replica set primary..." forever.
Each marker now records the revision that wrote it, and one from a different
revision is ignored and cleared - a marker with no revision recorded at all is
stale by definition, which is precisely what the affected instances carry.
Within one revision nothing changes, so the loop protection still holds; and not
knowing the revision is never taken as evidence that it changed.
</details>
**Notifications** - and the database write behind a slow login.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/184e1713d">The notification tray is capped, so SQLite is not rewriting an ever-growing array</a>. Thanks to Nissulya and xet7.</summary>
An instance reports FerretDB at 737% CPU, logins over a minute, boards not
appearing, and logs full of `database is locked (5) (SQLITE_BUSY)`. The stack
names the same write every time: `addNotification`.
That is an `$addToSet` on `profile.notifications`, an array inside the user
document - so adding one entry reads the whole document, scans the array and
writes the document back, at a cost proportional to the array. FerretDB on
SQLite has a single writer, so those rewrites queue and start failing, and
login, which also writes to the user document, queues behind them.
It grows without limit because the existing cleanup only removes notifications
that have been READ. A user who never clears their tray accumulates entries
forever. The same pass now also keeps the newest
`NOTIFICATION_TRAY_MAX_PER_USER` (default 1000) and drops the rest. It is
applied to what is left after the expiry pull, a user needing no change is not
written to at all, and it stays one write per user. This bounds the array; it
does not make SQLite a multi-writer engine, and a busy instance still wants the
PostgreSQL backend.
</details>
**Cards and the Admin Panel** - two things people asked for in the same thread.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6420819cf">Clicking an open card closes it, and a focused checkbox is not drawn as a diamond</a>. Thanks to csonkaoszimt, Heart1010 and xet7.</summary>
Closing a card by clicking it again was not a missing feature - it was an
unreachable one. The handler already ended with a branch that closed the open
card, but the TITLE branch above it returned first, and a minicard's title
covers most of the minicard. So the second click almost always re-opened the
card that was already open, and the toggle worked only if you managed to miss
the title. The title branch now makes the same decision, and on a phone, where
the card is a popup, the second click closes that.
The Problems page glitch in the screenshot is the focus ring. The Admin Panel
draws its checkboxes as a square that morphs into a tick, and the tick IS a
40-degree rotation of the element - so a browser draws its focus ring around a
rotated box, and a checkbox that is both checked and focused (which is what one
you just clicked is) comes out as a blue diamond. The ring moves to the row that
contains it, which is not rotated; keyboard focus stays visible.
</details>
**Card dates** - what a typed date actually becomes.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0c5cb99b7">A typed two-digit year is refused instead of stored as the year 26</a>. Thanks to xet7.</summary>
From email feedback: "If i write the expiration date with the keyboard it turns
red, if i choose it with the date picker it is yellow. Can you please tell me
the difference?" The colour was never the difference - the YEAR was.
`<input type="date">` reports its value as YYYY-MM-DD, but a browser lets the
year sub-field be typed as two digits and reports exactly that: entering
31-12-26 gives "0026-12-31", the year 26 AD. That is a valid Date, so nothing
refused it, and the card was saved with a due date two thousand years in the
past. Red is what an overdue date looks like. The attached screenshot shows it:
the yellow badges read "31-12-2026" and the red ones "31-12-26".
Saving now refuses a year outside 1000-9999 and says which digits are missing.
It refuses rather than silently correcting 0026 to 2026, because that would be a
guess about a date other people's reminders hang off.
</details>
**Old template containers** - the boards nobody asked for.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/80f551872">Remove the Templates containers that were made for accounts which never used them</a>. Thanks to xet7.</summary>
From email feedback: FerretDB at 190-350% CPU on an instance with 14490 boards,
of which 13404 are template containers, for 9264 accounts of which 478 have ever
logged in.
Before v10.00 every new account got a "Templates" container board at signup
whether or not the person ever saved a template. v10.00 made that lazy
(\#2339, \#5850), so no new account creates one - but nothing removed the ones
already made, and they are not visible enough for anybody to delete by hand. On
that instance they are 13x the boards collection, and every query that touches
boards carries it.
Because this deletes boards, the rule for what may go is narrow: only a
container nobody ever used. A template saved into it, a list, swimlane or card,
a second member, a rename, a star or a manual archive all keep it, and every
board that is kept reports why. A rename is judged only against the titles the
app itself used, so a container whose default name is in another language is not
deleted for it. The default is a dry run showing what WOULD go; deleting takes a
second, explicit request.
</details>
**The snap database** - which copy of the data it serves.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b1e7b1e53">A FerretDB copy older than the MongoDB beside it is never served</a>. Thanks to markusst1982 and xet7.</summary>
After upgrading from v6 the reporter saw *"the state of the data from days ago"*
and suspected a `snap revert` done four weeks earlier. They were right about the
cause, and nothing was lost.
The MongoDB to FerretDB migration copies MongoDB into SQLite **once** and writes
`.migration-to-ferretdb-done`. That copy is a snapshot; nothing keeps it in
step. Revert the snap to a revision that runs `mongod` and WeKan carries on
writing to MongoDB - for four weeks here - while the finished SQLite sits frozen
at the date it was made. Refresh forward again and the snap saw a marker plus a
non-empty SQLite, called that a completed migration and switched onto it. Every
board and card written during the revert was still on disk and simply not being
served.
The data was never in danger: it lives in `$SNAP_COMMON`, which is shared across
revisions and is **not** rolled back by a revert (unlike `$SNAP_DATA`, which is
per-revision). What was wrong was *which* of the two copies got served, and
nothing compared their ages.
A new check answers exactly that: `mongod` rewrites its WiredTiger files on
every commit, so the newest mtime among them is when MongoDB was last written
to, and later than the marker means the copy is behind. It counts data files
only - a newer `mongodb.log` means the snap was started, not that the database
changed - and allows a margin, because the migration stops its own temporary
source `mongod` moments after writing the marker. Anything it cannot tell is
reported as NOT stale, since the callers act on a yes.
Where `mongod` runs, the snap now stays on MongoDB, which has the newest data.
Where `mongod` cannot start at all - the case that forces the migration in the
first place - it migrates again from scratch rather than serve the old copy; the
migration reads with its own temporary `mongod` and the 4.2/3.2 readers, so it
still reaches everything written since. And `wekan-control` gains the second
half of a guard it already had: it refused to start an *empty* FerretDB while
MongoDB had data, and a *full but out-of-date* one looks worse, because WeKan
comes up with everything present except the last weeks.
</details>
**Board and card drag** - what scrolls while a card is held.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8456170d4">Dragging a card down scrolls the list, not the whole board</a>. Thanks to markusst1982 and xet7.</summary>
*"Upwards is no problem, the Line scrolls automaticly up, but this does not work
downwards. The whole Page/Site scrolls down and not ne Line"*.
The card drag auto-scrolls at the edges. Horizontally it picks the lane under
the pointer ([#443](https://github.com/wekan/wekan/issues/443)); vertically it picked
nothing, and always scrolled `.board-canvas` - which holds the swimlanes -
rather than the `.list-body` under the pointer, which is `overflow-y: scroll`
and holds the cards. Scrolling the canvas moves the whole board.
The asymmetry is what makes it reproducible. Dragging **up**, the canvas is
usually already at the top, so the handler did nothing and jQuery UI's own
scroll option - which acts on the placeholder's scroll parent, the list body -
scrolled the list, which is why up always worked. Dragging **down**, the canvas
nearly always has room left, so the handler fired first and scrolled the board
instead.
The list under the pointer is scrolled first now, and the board only once that
list cannot go further - so a drag down a long list scrolls the list, and a drag
past the end of it moves on to the board, which is what dragging a card into
another swimlane needs.
</details>
**The Helm chart index** - which charts it lists.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a442959dd">List only the charts whose container images still exist</a>. Thanks to xet7.</summary>
Backfilling the index taught this within the hour: Artifact Hub scans every
entry and mailed a list of errors.
```
error scanning image ghcr.io/wekan/wekan:v9.62: image not found error scanning
image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found
```
That was this side's doing. The rebuild listed every package on gh-pages, and a
chart is a POINTER TO CONTAINER IMAGES - one whose images have been deleted
installs and then fails at the pull, so listing it says the repository is broken
when the repository is fine and the images are gone. 135 of the 360 packages are
in that state, from two unrelated causes: **six WeKan images were never pushed**
(v8.30, v9.12, v9.14, v9.38, v9.39, v9.62 - releases whose own docker job
failed, and exactly the six Artifact Hub named), and **129 older charts vendor
the Bitnami mongodb subchart** and pin tags Bitnami has since deleted. Charts
from 8.41 on vendor groundhog2k's mongodb, which uses the official `mongo` image
and is unaffected.
The index now holds 225 entries: every one of the 216 that were listed before -
none dropped - plus the 9 backfilled packages whose images all resolve. The
exclusions are recorded in `unindexed.txt` beside the packages, not decided per
run, so a rebuild during a release cannot depend on reaching two registries, and
an image that comes back is one deleted line away from being listed again. The
`.tgz` files stay, so direct URLs keep working.
Two things this shook out. `--check-images` asks each registry with ITS OWN 401
challenge instead of a hard-coded token URL per host - the first attempt
reported every quay.io image as missing, including `quay.io/wekan/wekan:latest`,
which plainly exists. And an image that cannot be checked is never treated as
missing, only a definite 404, so a registry hiccup cannot silently unpublish
charts. `release-charts.sh` now refuses to publish a chart at all when
`ghcr.io/wekan/wekan:v<version>` does not exist, which is what created these six
in the first place.
</details>
and has the following developer-facing fixes:
**The test run** - a stage that failed for a reason that was not about WeKan.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/09a525ae8">The database-conformance run no longer opens a debug port nothing in it uses</a>. Thanks to xet7.</summary>
Every backend of the conformance stage failed before a single query was
compared: "Failed to create debug handler ... listen tcp 127.0.0.1:8088: bind:
address already in use", then "FerretDB did not start on this backend" for each
of them.
FerretDB opens a debug handler for metrics and profiling at 127.0.0.1:8088 by
default and EXITS when that address is taken, so an unrelated FerretDB running
on the machine made the whole stage report a database problem that was nothing
of the sort - as it would for anyone with anything on that port.
The script already takes this seriously for the two ports it knows about: it
picks a free wire port, makes both overridable, and says in its own comment that
they are chosen so it can run while something else is running. The debug port
was simply never passed. Nothing in the run queries it, so it is not opened at
all - which is also what FerretDB's own integration tests effectively do,
choosing a random debug port rather than the default.
</details>
**The snap build** - the part that could end it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b6d0be0f4">Two more mongo42 give-up paths deleted the directory their stage filter names</a>. Thanks to xet7.</summary>
The earlier fix for this covered one of the three ways the mongo42 part gives up
- the unsupported-architecture exit. The other two removed the whole staged
directory and exited 0, which leaves `stage: mongo42` naming a path that is not
there, and snapcraft ends the build on that rather than skipping it.
Those two are reached on amd64 and arm64, where the binary IS downloaded:
OpenSSL 1.1 unavailable for the architecture, or the staged mongod 4.2 failing
the check that it actually runs. Either would have ended the snap build for the
two architectures that matter most, the same way it ended all four Launchpad
ones. Both now clear the CONTENTS and keep the directory; an empty one still
means "no 4.2 reader", because every use is guarded on the binary rather than
the directory.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.81 2026-08-11 WeKan ® release
**In short:** the release pipeline, and two bugs that stopped WeKan starting at
all. **FerretDB** advertises its own listen address as the member list of a
one-node replica set, so the MongoDB driver threw away the host in `MONGO_URL`
and dialled `0.0.0.0:27017` instead - a fresh `docker compose up` could not
reach its database, and every FerretDB compose file now says
`directConnection=true`. **All Boards on a phone** could not be scrolled to its
last boards, its Table view could not be scrolled at all, single rows of board
icons were drawn several times their proper height, and the icons sat too close
together - four separate causes, one of them a `calc(100dvh - 48px)` in
`layouts.css` that guessed at a header height which is measured at runtime
precisely because it is never one number. On the release side: the **Mac x64
bundle** has never once been built, because its runner label was retired by
GitHub and a retired label queues forever instead of failing - and cancelling
that queue is what silently skipped the **charts**, **ucs** and **nextcloud**
jobs. The **armv6 bundle** was being assembled in a soft-float ARMv5 userland
that its own hard-float Node.js cannot start in, and **every Launchpad snap**
was ended by an optional MongoDB-migration part that stages a path it does not
build outside amd64 and arm64. Below that: a report of the WeKan releases the
Helm chart index is missing, and the duplicate entries found in it. The
binaries below are v10.80's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-amd64) | v1.48.0 | `2737687fd29a8a761cd960e45f300b68cf7b4a87d50c4cc5280bcbd42b6aa163` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-arm64) | v1.48.0 | `5ae705dd49515a4ecd4e295c3b9aa4f3b454fad78613ec60fb99316bd7c34e3f` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-loong64) | v1.48.0 | `06ec86263455a7b598d22a87df0e044ea73ab5a3b72e96ad12ebed03c1374ac2` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-arm64) | v1.48.0 | `9b15f4c10e473cd0a2c4feb4cb43e18042bd60c7035ec66cab3cfbe13edaabab` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-amd64) | v1.48.0 | `4e188246dfa33bccef4cdd86701bc498b037cb3e91f579ff0dccb93aa0ef03ad` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-ppc64le) | v1.48.0 | `0400cd6dfc3d10d987a0fe80d75baa86c03c19170770fa2e602c92d558c3cfa6` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-riscv64) | v1.48.0 | `d37c35af988670b9ed182b8c5966c06a06362f6c6ace6aebd93ccdfa32c9a26b` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-s390x) | v1.48.0 | `6c7d61fbb8c79b2e8733be8f71910f710e8c5cd25208c451bdc513c8313b0340` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-win64.exe) | v1.48.0 | `ea57e1bcd153b51d2065ab01515b21ec05d8f615444c15603ab8158b8a661dd2` |
This release fixes the following bugs:
**FerretDB** - the default database, and how WeKan is told to reach it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/e1c6221f2">Connect with directConnection=true, so a fresh docker compose up starts</a>. Thanks to Dandrass and xet7.</summary>
A new install with nothing changed but the port and `ROOT_URL` could not reach
the database:
```
MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
reason: TopologyDescription { type: 'ReplicaSetNoPrimary',
servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
setName: 'rs0', ... }
```
`0.0.0.0` is in no compose file. It is FerretDB's own listen address, and the
driver was handed it by the server. The `ferretdb` service runs with
`--repl-set-name=rs0` - added in
[#6480](https://github.com/wekan/wekan/issues/6480) so Meteor can tail an OpLog
instead of poll-and-diff - so FerretDB answers the `hello` handshake as a
one-member replica set and fills `hosts`, `me` and `primary` with its
`--listen-addr`. A driver not in direct-connection mode reads that as an
invitation to do replica-set discovery: it adopts the advertised member list and
drops the seed it was given, because the server reports a name other than the
one that was dialled. `mongodb://ferretdb:27017` became `0.0.0.0:27017`, which
inside the `wekan-app` container is that container itself.
Measured against FerretDB v1.49.0 with the driver the bundle ships:
without the parameter the topology ends up `ReplicaSetWithPrimary` with the seed
discarded, with it the topology is `Single` on the host that was given.
It costs nothing else - the handshake still reports `setName: rs0`, the only
thing Meteor checks before it will tail an OpLog. All five FerretDB v1 compose
files carry it; the MongoDB ones deliberately do not, being real replica sets
whose members are reachable under the names they advertise.
</details>
**All Boards on a phone** - the scroll, the tiles and the spacing between them.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/c0a5c2b7c">The board list scrolls to its last board, the Table view scrolls at all, and the tiles are their own size</a>. Thanks to mimZD and xet7.</summary>
Four things at once, in an issue reopened against 10.10, 10.37, 10.38, 10.72 and
10.77.
THE SCROLL. Every earlier fix removed viewport arithmetic from `boardsList.css`.
The last piece of it was in `layouts.css`:
`body.mobile-mode #content { height: calc(100dvh - 48px) }`. 48px is a guess at
the height of the header, and the header is not 48px and is not any one number -
`--wekan-header-height` is published from a `ResizeObserver` for exactly this
reason. On a phone whose bar wraps, `#content` was taller than the room under
the header, its bottom sat below the screen, and `body.mobile-mode` is
`position: fixed` and `overflow: hidden`, so that strip is unreachable. Nothing
needs computing: `body` is a flex column of one viewport and `#content` is its
`flex: 1` item.
THE TABLE VIEW. The right column has two branches - the board icons and
`+tablePage` - and only the icons were ever given a scroller, so in Table view
the rows past the fold were clipped with nothing to scroll.
THE 4x-TALL TILES. A grid defaults to `align-content: stretch`, so a list
shorter than its column has the leftover height divided among its rows and each
tile grown into it - and a board tile paints its colour over the whole cell.
This was fixed once, but only on `.board-list.mobile-view`; the phone media
query builds a grid too, and a narrow window that is not a mini screen took
that path.
THE SPACING. An 8px grid gap plus a `margin-bottom: 0.5rem` per tile: 16px
between rows and 8px between columns, which reads as crowded sideways. One value
now, in both directions. The mobile full-screen popup had the same class of bug
as the first and states `dvh` now too. Desktop is unaffected.
</details>
and fixes the following release-pipeline failures:
**The release workflow** - which jobs run, and on what.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7b8c37709">The retired Mac runner, the jobs a cancellation skipped, and the armv6 userland</a>. Thanks to xet7.</summary>
`build-mac-x64` asked for `macos-13`, and GitHub has retired that image -
`actions/runner-images` publishes macos-14, macos-15 and macos-26 only. A
retired label does not fail: the job QUEUES, for a runner that is never coming.
It has been sitting there every release until cancelled by hand, and no WeKan
release has ever carried a `wekan-<version>-mac-x64.zip` because of it. Intel
macOS was renamed, not removed: `macos-15-intel`, which is what TSC already
builds on.
Cancelling it is what skipped `charts`, `ucs` and `nextcloud`: a cancelled job
cancels the run, and a cancelling run skips every job that has not started - so
all three were skipped the second the `docker` job they wait on succeeded, after
everything had already shipped. A job whose `if` is `always()` still runs while
a run is cancelling, so the three now say `always()` and name the needs that
must have succeeded.
The armv6 bundle died on
`qemu-arm: Could not open '/lib/ld-linux-armhf.so.3'`. Debian has no ARMv6 port
- `debian:trixie` publishes arm/v5 and arm/v7 - and containerd treats a lower
ARM variant as compatible, so `--platform linux/arm/v6` quietly resolved to
arm/v5: Debian armel, soft-float, with no hard-float loader in it. The preflight
compared the architecture and ignored the variant, so it never said so; it
compares both now, and armv6 is built in Debian's arm/v7 container, which is
armhf and runs node-armv6 fine. Nothing ARMv7 reaches the bundle - the container
compiles nothing, and node, FerretDB and the MongoDB tools are all downloaded
already built for ARMv6 - except the bundled qemu-user, which is copied out of
the container and is therefore skipped for that one bundle.
</details>
**The snap** - what the four Launchpad architectures were really failing on.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/18da0d261">An optional part was ending every Launchpad build</a>. Thanks to xet7.</summary>
armhf, s390x, ppc64el and riscv64 all failed, three attempts each, and the job
reported it as a Launchpad problem: "often an OOM in the Meteor npm install, or
a transient build-farm reset; re-run". It was neither. The build log said the
same deterministic thing every time:
```
Staging mongo42
Failed to copy '/build/.../parts/mongo42/install/mongo42': no such file or
directory. Build failed
```
The `mongo42` part downloads MongoDB 4.2 so a database from an old MongoDB snap
can still be migrated ([#6471](https://github.com/wekan/wekan/issues/6471)), and
MongoDB publishes 4.2 for amd64 and arm64 only. Everywhere else the part prints
"nothing to migrate from there; skipping" and exits 0 having created nothing -
its own comment calls that optional by design. It was not: the part also carries
`stage: [mongo42]`, and snapcraft does not skip a filter whose path is missing,
it ends the build. So a migration helper no exotic architecture has ever needed
took the whole snap down on exactly the four that can only be built on
Launchpad. The directory is created before anything can decide to skip; the
binary is still downloaded only where it exists.
riscv64 additionally lost its last attempt to `npm ERR! code ECONNRESET` on one
tarball, after half an hour of building - every request there goes through
Launchpad's proxy - so that `npm install` is retried three times. The
`::error::` no longer guesses: it sends the reader to the build log printed
above it.
</details>
and improves the release tooling:
**The Helm chart index** - which WeKan releases it lists.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8248892e3">Report the releases the chart index is missing, and repair its duplicates</a>. Thanks to xet7.</summary>
A chart entry is written once, during the release it belongs to, so a release
whose `charts` job did not run leaves a hole nothing ever fills. Counted against
the live index: 216 of 690 WeKan releases have a chart entry.
`releases/backfill-charts.sh` answers which releases the index should list - the
ones that exist and can be installed. 216 are kept (never repackaged; that would
change a digest helm clients have seen), 369 could be built, and 162 are OMITTED
because they have no container image on ghcr: a chart is a pointer to an image,
so an entry for one of those is an install that fails at the pull. The index is
rebuilt from the packages actually present, so omission needs no bookkeeping.
It also found something already wrong: the published index has four entries for
9.36.0 and two for 10.30.0, each with a different digest and the same url,
because the release script prepends an entry every time it runs. At most one of
those digests can be the one of the package really served, and a helm client
that picks another fails the integrity check on a good file. The rebuild keeps
the entry whose digest matches the package, falling back to the newest only when
there is no package to compare against. Nothing is written without `--apply` and
nothing is pushed without `--push`; the charts job runs it in plan mode only,
into the run summary.
</details>
- [The chart-index report is a Release menu entry in build.sh and build.bat](https://github.com/wekan/wekan/commit/01f691ba3). Thanks to xet7.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1a2ec0633">Rebuild the Helm index.yaml from the chart packages it actually serves</a>. Thanks to xet7.</summary>
With the charts repository cloned it became clear the index had drifted from its
own packages in four ways at once, all from the same habit: an entry was written
by COPYING the previous entry and substituting a few fields, so the index was
being kept as the record rather than as a description of the `.tgz` files beside
it.
**146 packages had no entry at all** - 362 packages, 220 entries. They are
downloadable by URL but invisible to `helm search`, to
`helm install wekan --version 9.63.0` and to Artifact Hub. **9.36.0 had four
entries and 10.30.0 two**, each copy with a different digest and the same url,
so a client could pick a digest matching no file and fail integrity on a good
package. **appVersion was the CHART version on every entry** - `10.79.0` where
the package says `10.79`, which is the WeKan version a reader is looking for.
And **every entry claimed the mongodb subchart was 0.7.2**, a field nobody
substituted, while the packages had long since moved to 0.7.6.
`releases/reindex-charts.py` derives the index from the packages, reading each
one's `Chart.yaml` out of the archive, so an entry cannot describe a package
wrongly. Nothing is invented: `created` is preserved exactly on every entry
whose digest matches its package, and a package being added takes the date
it was committed to `gh-pages`, which is when it was really published. Two 2023
packaging slips - `wekan-1.2.7.tgz` containing chart 1.2.6, and `wekan-6.96.tgz`
containing 6.9.6 - are reported and left out rather than making one version mean
two files; both files stay on the server.
The index went from 220 entries to 360, and was checked after writing: no
repeated version, every digest equal to the sha256 of its file, every file
present, and no version that was listed before missing.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a434bb4bd">Both release workflows publish the chart, and the index is derived rather than edited</a>. Thanks to xet7.</summary>
The charts repository keeps the chart SOURCE on `main` and the published
packages plus `index.yaml` on `gh-pages`, and its own two scripts move between
them: `release.sh` commits the source, tars `wekan/` into
`wekan-<version>.0.tgz`, checks out gh-pages and drops the package there;
`release2.sh` commits and pushes. `release-charts.sh` drives both and owns the
index in between - and that middle part is what changes here.
**The index is now rebuilt from the packages** instead of being edited. It used
to copy the newest entry, substitute a few fields and prepend the result, and
all four of the index's defects came from exactly that. Deriving it makes them
impossible rather than fixed: one entry per package, digest computed from the
file, fields read out of the archive, and any package that was missed picked up
on the next run.
**The package is checked against its own filename before it is indexed.**
`release.sh` names the tarball from its argument while the version INSIDE comes
from the `Chart.yaml` that was just edited, so when those drift the repository
gains a file called one version that declares another - which is how
`wekan-1.2.7.tgz` (containing 1.2.6) and `wekan-6.96.tgz` (containing 6.9.6)
came to exist. Both are now removed, and a package like them stops the release
with a message instead of being published.
**Release All Missing publishes a chart too.** It could rebuild any missing
bundle but not a missing chart, so a release that never got one never would -
its header even said the charts were out of scope. It has a charts job now,
guarded twice: nothing happens without a token that can push to wekan/charts,
and nothing happens if the chart for that version is already published, because
re-publishing would re-tar the package and change the digest of a chart people
have already pulled.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v10.80 2026-08-10 WeKan ® release
**In short:** the **Admin Panel**, in the two panes v10.79 had just changed.
@@ -1891,7 +2775,7 @@ one security event is pivot on it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a260a53e">The database marks the operations WeKan never issues, so an operator sees them</a>. Thanks to xet7.</summary>
<summary><a href="https://github.com/wekan/FerretDB/commit/a260a53e">The database marks the operations WeKan never issues, so an operator sees them</a>. Thanks to xet7.</summary>
FerretDB is reached over a local socket by one application, whose driver is a
Meteor 3 one. That makes a class of operations interesting by their mere
@@ -40468,9 +41352,9 @@ separate option to hide logo at Admin Panel/Layout
</details>
- Added and then reverted option to redirect OIDC OAuth2 login
[part1](https://github.com/wekan/wekan-ldap/commit/82a894ac20ba9e7c6fdf053cff1721cab709bf8a),
[part1](https://github.com/wekan/wekan/commit/188d42dcd),
[part
2](https://github.com/wekan/wekan-ldap/commit/36900cc360d0d406f8fba5e43378f85c92747870)
2](https://github.com/wekan/wekan/commit/d173844f9)
and
[part3](https://github.com/wekan/wekan/commit/7919ae362866c0cacf2a486bf91b12e4d25807d7).
This does not work yet. In Progress. Thanks to xet7.
+1 -1
View File
@@ -20,7 +20,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=10.80
ARG VERSION=10.83
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v10.80.0"
appVersion: "v10.83.0"
files:
userUploads:
- README.md
+6
View File
@@ -253,6 +253,8 @@ echo 19^) Delete a git tag, locally and on the remote
echo 20^) Move the 'stable' tag to HEAD
echo 21^) Release the wekan-ondra / wekan-gantt-gpl variants, part 1
echo 22^) Release the wekan-ondra / wekan-gantt-gpl variants, part 2
echo 23^) Report ^(or repair^) the Helm chart index for past releases
echo 24^) Rebuild the Helm index.yaml from the chart packages
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/release-all.sh" ""
@@ -277,6 +279,8 @@ if "%choice%"=="19" call :rel_run "releases/delete-tag.sh" "Version tag, e.g. v1
if "%choice%"=="20" call :rel_cmd "git tag --force stable HEAD && git push --tags --force && git push --follow-tags" ""
if "%choice%"=="21" call :rel_run "releases/release-ondra-1.sh" ""
if "%choice%"=="22" call :rel_run "releases/release-ondra-2.sh" ""
if "%choice%"=="23" call :rel_run "releases/backfill-charts.sh" ""
if "%choice%"=="24" call :rel_run "releases/reindex-charts.py" ""
if "%choice%"=="0" goto menu_releases
goto rel_release
@@ -628,6 +632,8 @@ if /I "%K%"=="release-ln" (set "CMD=bash releases/release-ln.sh" ^& goto cli_go)
if /I "%K%"=="release-x2" (set "CMD=bash releases/release-x2.sh" ^& goto cli_go)
if /I "%K%"=="release-cleanup" (set "CMD=bash releases/release-cleanup.sh" ^& goto cli_go)
if /I "%K%"=="release-charts" (set "CMD=bash releases/release-charts.sh" ^& goto cli_go)
if /I "%K%"=="backfill-charts" (set "CMD=bash releases/backfill-charts.sh" ^& goto cli_go)
if /I "%K%"=="reindex-charts" (set "CMD=python3 releases/reindex-charts.py" ^& goto cli_go)
if /I "%K%"=="release-website" (set "CMD=bash releases/release-website.sh" ^& goto cli_go)
if /I "%K%"=="npm-publish" (set "CMD=bash releases/npm-publish.sh" ^& goto cli_go)
if /I "%K%"=="test-download-urls" (set "CMD=bash releases/test-download-urls.sh" ^& goto cli_go)
+2
View File
@@ -1867,6 +1867,8 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
"Release|Move the 'stable' tag to HEAD|!git tag --force stable HEAD && git push --tags --force && git push --follow-tags|||stable-tag"
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 1|releases/release-ondra-1.sh|||"
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 2|releases/release-ondra-2.sh|||"
"Release|Report (or repair) the Helm chart index for past releases|releases/backfill-charts.sh|||"
"Release|Rebuild the Helm index.yaml from the chart packages|releases/reindex-charts.py|||"
"Snap|Build the snap from snapcraft.yaml|releases/snap-build.sh|||"
"Snap|Install the locally built .snap|releases/snap-install.sh|||"
"Snap|Push one .snap to the Snap Store|releases/snap-push-to-store.sh|Path to the .snap file||"
+59 -4
View File
@@ -1193,7 +1193,8 @@
(e.g. a laptop in mobile mode) gets more columns instead of two stretched ones. */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(46%, 200px), 1fr));
gap: 8px;
/* The same single spacing value as the phone grid above, in both directions. */
gap: 10px;
/* #6488: sized by the column it lives in (the flex chain in the phone block
below), not by the viewport minus a guess at the bars above it. */
flex: 1 1 auto;
@@ -1210,7 +1211,8 @@
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 0.5rem;
/* The grid gap is the spacing; this added a second one, downwards only. */
margin-bottom: 0;
/* NO handle reserve here. The drag handle is positioned INSIDE the tile, and
the tile already keeps its text clear of it - so 32px here was a second,
invisible reserve on top of that one. On a phone the tile is only ~97px
@@ -1340,18 +1342,42 @@
.board-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
/* ONE spacing value, in both directions. It used to be an 8px gap PLUS a
`margin-bottom: 0.5rem` on each li, so the tiles were 16px apart vertically
and 8px apart horizontally - which reads as "the icons are too close
together" side to side even though there is space between the rows. The
margin is gone (below) and the gap is the whole answer. */
gap: 10px;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0.25rem;
margin: 0;
/* #6488. Without these a grid's rows default to `align-content: stretch` and
its items to `align-items: stretch`, so when the list is SHORTER than the
column it is given - one or two rows of boards - the leftover height is
divided among the rows and each tile is grown to fill it. A board tile
paints its board colour over the whole cell, so a single row of boards
became one band several times the height of a normal icon: "some board
icons are 4x normal height or more".
This was fixed once, but only on `.board-list.mobile-view` - the class the
template sets from Utils.isMiniScreen(). A narrow WINDOW that is not a mini
screen (a desktop browser resized under 800px, a tablet in desktop mode)
matches this media query, becomes a grid here, and never got the fix. It
belongs on the rule that creates the grid. */
align-content: start;
align-items: start;
}
.board-list li {
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 0.5rem;
/* The grid gap spaces the tiles; a margin here spaced them a second time,
and only downwards. */
margin-bottom: 0;
/* ...and the tile is its own height, not the height of a stretched cell. */
align-self: start;
height: auto;
/* Same as `.board-list.mobile-view li` above: the tile keeps its own text
clear of the handle, so reserving the width a second time out here only
narrowed the title. */
@@ -1661,6 +1687,35 @@
flex: 0 0 auto;
}
/* ...and so does the pane's heading. Without this it is a flex item with the
default `flex-shrink: 1`, so in a column too short for its contents the
TITLE is what gets squeezed instead of the scroller below it growing a
scrollbar. */
.boards-right-grid > .admin-pane-title {
flex: 0 0 auto;
}
/* #6488. The right column has TWO branches, not one: the board icons, and -
when the view is Table - `+tablePage`, which is a `.table-page` (see
boardsList.jade). Only the icons were ever given a scroller, so in Table view
the table simply grew past the bottom of the column, and `#content .wrapper`
is `overflow: hidden`, so the rows past the fold were clipped with no way to
reach them. Every section of All Boards can be shown as a table - Starred,
Templates, Remaining, Workspaces, Archive - so that was the whole page in
one of its two views.
The same shape as the board list: take what the column has left, be allowed
to be shorter than the content, and scroll inside. The table is also WIDER
than a phone, so this one scrolls in both directions. */
.boards-right-grid > .table-page {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
/* The left menu (Starred / Templates / Remaining / Workspaces / Add board) is its
own vertical scroll area, so when it is taller than the screen you can drag-scroll
down to its lower items — the board icons already scroll in their own column on the
+30
View File
@@ -1146,3 +1146,33 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
user-select: none;
margin: 8px 0;
}
/* #1591: foldable feature groups on the opened card.
The title is the handle. Everything after it inside the section is what folds
away - a sibling rule rather than "hide .card-details-item-content", because
the sections are not uniform inside: all sixteen start with an
h3.card-details-item-title, but only three wrap what follows in a content
element. Hiding the siblings works whatever a section puts there. */
.card-details-item > .card-details-item-title {
cursor: pointer;
user-select: none;
}
/* The caret, drawn on the title rather than added to sixteen templates. It
points down when open and right when folded, like the checklist one. */
.card-details-item > .card-details-item-title::after {
content: "\f0d7"; /* fa-caret-down */
font-family: FontAwesome;
font-size: 12px;
color: #808080;
margin-inline-start: 6px;
vertical-align: middle;
}
.card-details-item.is-collapsed > .card-details-item-title::after {
content: "\f0da"; /* fa-caret-right */
}
.card-details-item.is-collapsed > *:not(.card-details-item-title) {
display: none;
}
+62
View File
@@ -513,6 +513,31 @@ Template.cardDetails.onRendered(function () {
// below throws "Can't select in removed DomRange", so bail out early.
if (this.view && this.view.isDestroyed) return;
// #1591: re-apply the sections this user folded. The click handler toggles the
// class as well as storing it, but on a fresh render - reopening the card, a
// reload - only the stored state exists, and the title is plain markup that
// nothing re-renders from the profile.
this.autorun(() => {
const user = ReactiveCache.getCurrentUser();
const card = Template.currentData();
if (!user || !card || !card._id) return;
// Read inside the autorun so a fold made in another tab arrives here too.
const stored = (user.profile && user.profile.collapsedCardSections) || {};
const forCard = stored[card._id] || {};
Meteor.defer(() => {
if (this.view && this.view.isDestroyed) return;
this.$('.card-details-item').each((index, item) => {
const named = Array.from(item.classList).find(
c => c.startsWith('card-details-item-') &&
c !== 'card-details-item-title' &&
c !== 'card-details-item-content');
if (!named) return;
const key = named.replace('card-details-item-', '');
item.classList.toggle('is-collapsed', forCard[key] === true);
});
});
});
// #6465: place the window beside its minicard (X only) once it has been laid
// out, so the width measured here is the one the stylesheet gave it.
const $cardDetails = this.$('.card-details').first();
@@ -828,7 +853,44 @@ Template.cardDetails.helpers({
},
});
// #1591: fold any feature group on the opened card - Labels, Members, Dates,
// Custom Fields, and the rest.
//
// Done here, once, rather than by editing sixteen sections in cardDetails.jade,
// because the sections are NOT uniform inside: they all begin with an
// `h3.card-details-item-title`, but only three of them wrap what follows in a
// `.card-details-item-content`. So the title is the handle, and "collapsed"
// hides every sibling after it (the CSS rule) - which works whatever the section
// puts there.
//
// The state is the same per-user map the checklists use
// (profile.collapsedCardSections), keyed by the section's own name, so it
// survives a reload and belongs to the reader rather than to the card.
function cardSectionKeyOf(element) {
const item = element.closest('.card-details-item');
if (!item) return null;
// 'card-details-item-labels' -> 'labels'. The class that is not the generic
// one IS the section's name; there is no separate id to invent.
const named = Array.from(item.classList).find(
c => c.startsWith('card-details-item-') &&
c !== 'card-details-item-title' &&
c !== 'card-details-item-content');
return named ? named.replace('card-details-item-', '') : null;
}
Template.cardDetails.events({
'click .card-details-item > .card-details-item-title'(event) {
const key = cardSectionKeyOf(event.currentTarget);
const user = ReactiveCache.getCurrentUser();
const card = Template.currentData();
if (!key || !user || !card || !card._id) return;
const collapsed = user.getCollapsedCardSection(card._id, key) === true;
user.setCollapsedCardSection(card._id, key, !collapsed);
// Applied straight away as well as stored: the title is plain markup, so
// nothing re-renders on its own when the profile changes.
event.currentTarget.closest('.card-details-item')
.classList.toggle('is-collapsed', !collapsed);
},
[`${CSSEvents.transitionend} .js-card-details`](event, tpl) {
tpl.isLoaded.set(true);
},
+23
View File
@@ -191,3 +191,26 @@ textarea.js-edit-checklist-item {
.edit-controls label.toggle-label {
margin-inline-start: 2px;
}
/* #1591: the fold caret on a checklist title. Sized and coloured like the
actions menu beside it so the two read as one row of controls, and given a
real hit area - it is a single glyph, and a caret you have to aim at is worse
than no caret. */
.checklist-title .checklist-collapse {
/* Logical, so the caret sits on the reading-start side under dir=rtl too
(tests/rtl.test.js enforces this across component CSS). */
float: inline-start;
margin-block-start: 2px;
margin-inline-end: 4px;
padding: 2px 4px;
color: #808080;
font-size: 14px;
line-height: 14px;
cursor: pointer;
text-decoration: none;
}
.checklist-title .checklist-collapse:hover,
.checklist-title .checklist-collapse:focus-visible {
color: #2980b9;
}
+13 -1
View File
@@ -38,6 +38,15 @@ template(name="checklistDetail")
else
.checklist-title
span
//- #1591: fold a long checklist out of the way. A caret rather than
another entry in the actions menu, because the whole point is to do it
at a glance while reading the card - and it is a view preference of
this user, so it does not touch the card.
a.fa.checklist-collapse.js-collapse-checklist(
class="{{#if checklistCollapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}"
title="{{#if checklistCollapsed}}{{_ 'expand-checklist'}}{{else}}{{_ 'collapse-checklist'}}{{/if}}"
role="button" tabindex="0"
aria-expanded="{{#if checklistCollapsed}}false{{else}}true{{/if}}")
if canModifyCard
a.fa.fa-navicon.checklist-details-menu.js-open-checklist-details-menu(title="{{_ 'checklistActionsPopup-title'}}")
@@ -52,12 +61,15 @@ template(name="checklistDetail")
+viewer
= checklist.title
//- The progress bar stays visible when folded: it is the summary of what was
folded away, so hiding it too would leave a title that says nothing.
if $gt finishedPercent 0
.checklist-progress-bar-container
.checklist-progress-text {{finishedPercent}}%
.checklist-progress-bar
.checklist-progress(style="width:{{finishedPercent}}%")
+checklistItems(checklist = checklist card = card)
unless checklistCollapsed
+checklistItems(checklist = checklist card = card)
template(name="checklistDeletePopup")
p {{_ 'confirm-checklist-delete-popup'}}
+41
View File
@@ -82,6 +82,47 @@ Template.checklistDetail.helpers({
const ret = this.checklist.finishedPercent();
return ret;
},
/** #1591: is this checklist folded for THIS user?
*
* Per-user, keyed by card and checklist, exactly like collapsed lists and
* swimlanes - not the checklist's own hideAllChecklistItems, which is a field
* on the checklist and so changes what everyone on the board sees. `null` from
* the getter means "never set", which is what makes the default (expanded)
* distinguishable from somebody having deliberately expanded it. */
checklistCollapsed() {
const checklist = this.checklist;
if (!checklist || !checklist._id) return false;
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
const cardId = (this.card && this.card._id) || checklist.cardId;
const stored = user.getCollapsedCardSection(
cardId, user.checklistSectionKey(checklist._id));
return stored === true;
},
});
Template.checklistDetail.events({
'click .js-collapse-checklist'(event) {
// The caret sits inside the title, which opens the inline rename form when
// clicked - so this must not reach it.
event.preventDefault();
event.stopPropagation();
const checklist = this.checklist;
const user = ReactiveCache.getCurrentUser();
if (!checklist || !checklist._id || !user) return;
const cardId = (this.card && this.card._id) || checklist.cardId;
const key = user.checklistSectionKey(checklist._id);
const collapsed = user.getCollapsedCardSection(cardId, key) === true;
user.setCollapsedCardSection(cardId, key, !collapsed);
},
'keydown .js-collapse-checklist'(event) {
// It is a link acting as a button, so it has to answer the keys a button
// answers or it is unreachable without a mouse.
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
$(event.currentTarget).trigger('click');
},
});
Template.checklists.helpers({
+19 -11
View File
@@ -265,6 +265,13 @@ template(name="editCardSortOrderPopup")
template(name="minicardChecklist")
.minicard-checklist
.checklist-header
//- #1591: the same caret as on the opened card, and the same stored key,
so folding a checklist folds it in both places.
a.fa.checklist-collapse.js-collapse-checklist(
class="{{#if checklistCollapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}"
title="{{#if checklistCollapsed}}{{_ 'expand-checklist'}}{{else}}{{_ 'collapse-checklist'}}{{/if}}"
role="button" tabindex="0"
aria-expanded="{{#if checklistCollapsed}}false{{else}}true{{/if}}")
if canModifyCard
+inlinedForm(classNames="js-edit-checklist-title" checklist=checklist)
+editChecklistForm(checklist=checklist)
@@ -275,16 +282,17 @@ template(name="minicardChecklist")
if canModifyCard
a.checklist-menu.js-open-checklist-menu(title="{{_ 'checklistActionsPopup-title'}}")
i.fa.fa-bars
.checklist-items.js-checklist-items
each visibleItems
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
+editChecklistItemForm(type='item' item=. checklist=checklist)
unless checklistCollapsed
.checklist-items.js-checklist-items
each visibleItems
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
+editChecklistItemForm(type='item' item=. checklist=checklist)
else
+checklistItemDetail(item=. checklist=checklist card=card)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
else
+checklistItemDetail(item=. checklist=checklist card=card)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
else
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
i.fa.fa-plus
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
i.fa.fa-plus
+32
View File
@@ -375,6 +375,18 @@ Template.minicard.events({
});
Template.minicardChecklist.helpers({
/** #1591: folded for THIS user, under the same key the opened card uses, so
* the two agree. `this.checklist || this` because this template is called both
* with an explicit checklist and with one as the data context. */
checklistCollapsed() {
const checklist = this.checklist || this;
if (!checklist || !checklist._id) return false;
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
const cardId = (this.card && this.card._id) || checklist.cardId;
return user.getCollapsedCardSection(
cardId, user.checklistSectionKey(checklist._id)) === true;
},
visibleItems() {
const checklist = this.checklist || this;
const items = checklist.items();
@@ -416,6 +428,26 @@ function addMinicardChecklistItems(textarea, checklist) {
}
Template.minicardChecklist.events({
'click .js-collapse-checklist'(event) {
// A minicard is a link to the card, and the title opens the rename form -
// neither must happen because somebody folded a checklist.
event.preventDefault();
event.stopPropagation();
const data = Template.currentData();
const checklist = (data && data.checklist) || data || this;
const user = ReactiveCache.getCurrentUser();
if (!checklist || !checklist._id || !user) return;
const cardId = (data && data.card && data.card._id) || checklist.cardId;
const key = user.checklistSectionKey(checklist._id);
const collapsed = user.getCollapsedCardSection(cardId, key) === true;
user.setCollapsedCardSection(cardId, key, !collapsed);
},
'keydown .js-collapse-checklist'(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
$(event.currentTarget).trigger('click');
},
'click .js-convert-checklist-item-to-card'(event) {
event.stopPropagation();
const formData = Blaze.getData(event.currentTarget);
+40 -2
View File
@@ -423,8 +423,46 @@ Template.list.onRendered(function () {
scrolled = true;
}
}
// vertical auto-scroll stays on the board canvas
const canvas = document.querySelector('.board-canvas');
// #6584: VERTICALLY, the thing to scroll is the LIST the pointer is over,
// not the board canvas. `.list-body` is `overflow-y: scroll` (list.css) and
// is what holds the cards; `.board-canvas` is `overflow-y: auto` and holds
// the swimlanes. Scrolling the canvas moves the whole board, which is what
// the report describes: "The whole Page/Site scrolls down and not the Line".
//
// It only showed up DOWNWARDS, and the asymmetry is the tell. Dragging up,
// the canvas is usually already at scrollTop 0, so computeEdgeScroll
// returned null, nothing here fired, and jQuery UI's own `scroll` option -
// which acts on the placeholder's scrollParent, i.e. the list body - scrolled
// the list, exactly as expected. Dragging down, the canvas nearly always HAS
// room, so this fired first and scrolled the board instead of the list.
//
// The canvas is still scrolled, but only once the list under the pointer
// cannot go further that way - so a drag down a long list scrolls the list,
// and a drag past the end of it moves on to the board. Same shape as the
// horizontal case above, which already picks the lane under the pointer.
let scrolledList = false;
const listBodies = document.querySelectorAll('.list-body');
const listRects = Array.prototype.map.call(listBodies, el =>
el.getBoundingClientRect());
// findLaneUnderPointer is a plain rectangle hit-test; the name is about
// where it was first used, not what it can be given.
const listIndex = findLaneUnderPointer(listRects, event.clientX, event.clientY);
if (listIndex !== -1) {
const nextListTop = computeEdgeScroll({
pointer: event.clientY,
lowEdge: listRects[listIndex].top,
highEdge: listRects[listIndex].bottom,
scrollPos: listBodies[listIndex].scrollTop,
scrollSize: listBodies[listIndex].scrollHeight,
clientSize: listBodies[listIndex].clientHeight,
});
if (nextListTop !== null) {
listBodies[listIndex].scrollTop = nextListTop;
scrolled = true;
scrolledList = true;
}
}
const canvas = scrolledList ? null : document.querySelector('.board-canvas');
if (canvas) {
const crect = canvas.getBoundingClientRect();
const nextTop = computeEdgeScroll({
+16
View File
@@ -256,6 +256,16 @@ Template.listBody.onCreated(function () {
if (clickedTitle && !clickedLinkedReference) {
evt.stopImmediatePropagation();
evt.preventDefault();
// #6465: clicking the OPEN card again closes it. The toggle below has
// always been here, but this branch returned before ever reaching it - and
// the title covers most of the minicard, so in practice the second click
// almost always lands here and just re-opened the card that was already
// open. That is why it read as a missing feature rather than a dead
// branch. Same call as below, so both click targets behave alike.
if (Session.equals('currentCard', card._id)) {
Utils.goBoardId(Session.get('currentBoard'));
return;
}
Session.delete('popupCardId');
Session.delete('popupCardBoardId');
Session.set('currentCard', card._id);
@@ -265,6 +275,12 @@ Template.listBody.onCreated(function () {
if (Utils.isMiniScreen()) {
evt.preventDefault();
// ...and on a phone, where the card is a popup rather than a pane, the
// second click closes that popup for the same reason.
if (Session.equals('popupCardId', card._id) && Popup.isOpen()) {
Popup.back();
return;
}
Session.set('popupCardId', card._id);
this.cardDetailsPopup(evt);
} else if (Session.equals('currentCard', card._id)) {
+24 -2
View File
@@ -243,8 +243,30 @@ body.mobile-mode #content {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: calc(100vh - 48px);
height: calc(100dvh - 48px);
/* #6488, and the reason that issue kept coming back after being closed three
times: THE LAST PIECE OF VIEWPORT ARITHMETIC. This said
`height: calc(100dvh - 48px)`, and 48px is a guess at how tall the header is.
The header is not 48px and is not any one number - Utils publishes
--wekan-header-height from a ResizeObserver for exactly this reason, because
the quick-access bar WRAPS to a second and third row depending on the
language and the window width, and its own comment says "every fixed number
for it has been wrong for some of those".
On a phone, where the bar does wrap, #content was therefore taller than the
room under the header by however much the header exceeded 48px, so its
bottom sat BELOW the bottom of the screen. body.mobile-mode is
`position: fixed` and the phone rules make it `overflow: hidden`, so that
strip is unreachable: everything below it inherits the error through the
`height: 100%` chain (boardsList.css), and the last rows of the board list
end up where no gesture can reach them. That is "in mobile board menu you
cant scroll boards" - the list scrolls, it just ends off screen.
Nothing has to be computed here. body is a flex COLUMN of exactly one
viewport and this is its `flex: 1` item (below), so the space left under the
header - whatever the header turns out to be this paint - IS this box. The
arithmetic was overriding a correct answer with a wrong one.
`min-height: 0` so it can be SHORTER than its content: a flex item will not
shrink below its content otherwise, and an inner scroller whose ancestor
refuses to shrink never scrolls. */
min-height: 0;
overscroll-behavior: contain;
}
#content {
+18 -3
View File
@@ -893,14 +893,29 @@ body.mobile-mode .pop-over {
inset-inline-end: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
/* #6488. `100vh` is the viewport with the browser's toolbars HIDDEN, so a
`position: fixed` box sized that way reaches under them: on a phone the
bottom of this popup is off screen, and being fixed there is no gesture that
brings it back. `100dvh` is the viewport as it is right now. The `vh` pair
stays first as the fallback where `dvh` is unknown - the same fix
body.mobile-mode already carries in layouts.css. */
height: 100vh !important;
max-height: 100vh !important;
height: 100dvh !important;
max-height: 100dvh !important;
}
body.mobile-mode .pop-over .content-wrapper {
width: 100% !important;
height: calc(100vh - 48px) !important;
max-height: calc(100vh - 48px) !important;
/* #6488. This was `calc(100vh - 48px)`, which is two guesses at once: the
viewport measured with the toolbars hidden, minus a fixed 48px for the
popup's own title bar. When the title wraps - a long board name, a
translated label - the bar is taller than 48px and the list inside this
wrapper ends below the screen, so its last rows cannot be scrolled to.
Nothing needs measuring: `.pop-over` above is a flex column of exactly one
viewport, `.pop-over .header` is `flex-shrink: 0`, and this wrapper is
already `flex: 1 1 auto; min-height: 0` - so it is whatever the title bar
leaves, at whatever height the title bar really is. */
overflow-y: auto !important;
overflow-x: hidden !important;
}
@@ -456,6 +456,34 @@ body.left-menu-resizing-active {
/* …and exactly .materialCheckBox.is-checked: the square becomes the tick, which is
what the transition above animates. */
/* #6465: THE FOCUS RING MUST NOT BE DRAWN ON THE ROTATED BOX.
The checked state below turns the square into a tick by making two borders
transparent and rotating the element 40deg. A browser draws its focus ring
around the element as it is - rotated - so a checkbox that is both checked and
focused (which is exactly what a checkbox you just clicked is) comes out as a
blue DIAMOND sitting where the tick should be. That is the glitch reported on
the Problems page.
The ring is not simply removed: keyboard users need to see where they are. It
moves to the row that CONTAINS the checkbox, which is not rotated - every one
of these lives inside a label (problemsSummary.jade: `label.admin-problem-item
> input.js-problem-check`) - so the indicator is a normal rectangle around the
whole row, which is easier to see than a 13px box anyway.
`:focus-visible` rather than `:focus`, so clicking with a mouse does not leave
a ring behind; `:focus-within` is the fallback where :has() is not supported,
and lands on the same element. */
.setting-content input[type="checkbox"]:focus,
.setting-content input[type="checkbox"]:focus-visible {
outline: none;
}
.setting-content label:has(> input[type="checkbox"]:focus-visible) {
outline: 2px solid #2980b9;
outline-offset: 2px;
border-radius: 3px;
}
.setting-content input[type="checkbox"]:checked {
top: -4px;
inset-inline-start: -3px;
+27
View File
@@ -3,6 +3,13 @@ import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveCache } from '/imports/reactiveCache';
import { getCurrentCardFromContext } from '/client/lib/currentCard';
import { normalizeDigits } from '/imports/lib/dateUtils';
// The window a typed year has to fall in. Wide on purpose: WeKan holds real
// historical received dates and long-dated deadlines, so this is only meant to
// catch a year that cannot have been intended - the two-digit one a browser
// reports verbatim (0026), and the five-digit slip in the other direction.
const MIN_PLAUSIBLE_YEAR = 1000;
const MAX_PLAUSIBLE_YEAR = 9999;
import {
isValidDate,
formatTime,
@@ -165,6 +172,26 @@ export function datePickerEvents({ storeDate, deleteDate }) {
return;
}
// A TWO-DIGIT YEAR is a real date that is not the one anybody meant.
// `<input type="date">` reports YYYY-MM-DD, but the browser lets the year
// sub-field be typed as two digits and reports that literally: typing
// 31-12-26 gives "0026-12-31", the year 26 AD. Nothing above rejects it -
// it is a perfectly valid Date - so the card got a due date two thousand
// years in the past, which is why the reporter saw a date they had TYPED
// come out red (overdue) while the same date picked from the calendar,
// which always fills four digits, came out yellow (due soon). The colour
// was right; the year was wrong.
//
// Refused rather than corrected: 0026 could be meant as 2026, but guessing
// silently rewrites what somebody typed, and this is a date other people's
// reminders hang off. The error names the year so the fix is obvious.
const year = newCompleteDate.getFullYear();
if (year < MIN_PLAUSIBLE_YEAR || year > MAX_PLAUSIBLE_YEAR) {
tpl.datePicker.error.set('invalid-year');
evt.target.date.focus();
return;
}
storeDate.call(tpl, newCompleteDate);
Popup.back();
},
+14 -3
View File
@@ -218,8 +218,18 @@ services:
#- S3_SECRET_FILE=/run/secrets/s3_secret
#-----------------------------------------------------------------
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
- MONGO_URL=mongodb://ferretdb:27017/wekan
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
# directConnection the MongoDB driver does replica-set discovery: it drops the
# host written here and dials the advertised 0.0.0.0:27017 instead, which
# inside the wekan container is the wekan container itself — that is the
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
# `docker compose up` died with. directConnection=true keeps the driver on
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
# the handshake, which is still there.
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
#---------------------------------------------------------------
# ==== ROOT_URL SETTING ====
@@ -274,7 +284,8 @@ services:
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
# directConnection=true for the same reason as MONGO_URL above (#6582).
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
+14 -3
View File
@@ -220,8 +220,18 @@ services:
#- S3_SECRET_FILE=/run/secrets/s3_secret
#-----------------------------------------------------------------
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
- MONGO_URL=mongodb://ferretdb:27017/wekan
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
# directConnection the MongoDB driver does replica-set discovery: it drops the
# host written here and dials the advertised 0.0.0.0:27017 instead, which
# inside the wekan container is the wekan container itself — that is the
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
# `docker compose up` died with. directConnection=true keeps the driver on
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
# the handshake, which is still there.
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
#---------------------------------------------------------------
# ==== ROOT_URL SETTING ====
@@ -276,7 +286,8 @@ services:
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
# directConnection=true for the same reason as MONGO_URL above (#6582).
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
+14 -3
View File
@@ -207,8 +207,18 @@ services:
#- S3_SECRET_FILE=/run/secrets/s3_secret
#-----------------------------------------------------------------
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
- MONGO_URL=mongodb://ferretdb:27017/wekan
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
# directConnection the MongoDB driver does replica-set discovery: it drops the
# host written here and dials the advertised 0.0.0.0:27017 instead, which
# inside the wekan container is the wekan container itself — that is the
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
# `docker compose up` died with. directConnection=true keeps the driver on
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
# the handshake, which is still there.
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
#---------------------------------------------------------------
# ==== ROOT_URL SETTING ====
@@ -263,7 +273,8 @@ services:
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
# directConnection=true for the same reason as MONGO_URL above (#6582).
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
+14 -3
View File
@@ -224,8 +224,18 @@ services:
#- S3_SECRET_FILE=/run/secrets/s3_secret
#-----------------------------------------------------------------
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
- MONGO_URL=mongodb://ferretdb:27017/wekan
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
# directConnection the MongoDB driver does replica-set discovery: it drops the
# host written here and dials the advertised 0.0.0.0:27017 instead, which
# inside the wekan container is the wekan container itself — that is the
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
# `docker compose up` died with. directConnection=true keeps the driver on
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
# the handshake, which is still there.
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
#---------------------------------------------------------------
# ==== ROOT_URL SETTING ====
@@ -280,7 +290,8 @@ services:
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
# directConnection=true for the same reason as MONGO_URL above (#6582).
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
+14 -3
View File
@@ -178,8 +178,18 @@ services:
#- S3_SECRET_FILE=/run/secrets/s3_secret
#-----------------------------------------------------------------
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
- MONGO_URL=mongodb://ferretdb:27017/wekan
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
# directConnection the MongoDB driver does replica-set discovery: it drops the
# host written here and dials the advertised 0.0.0.0:27017 instead, which
# inside the wekan container is the wekan container itself — that is the
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
# `docker compose up` died with. directConnection=true keeps the driver on
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
# the handshake, which is still there.
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
#---------------------------------------------------------------
# ==== ROOT_URL SETTING ====
@@ -234,7 +244,8 @@ services:
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
# directConnection=true for the same reason as MONGO_URL above (#6582).
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
+51 -2
View File
@@ -7,8 +7,10 @@ the **default database of WeKan**: `docker-compose.yml` runs FerretDB v1 on its
embedded SQLite, so there is no separate database server to install at all.
Whichever backend is used, WeKan talks only to FerretDB
(`MONGO_URL=mongodb://ferretdb:27017/wekan`) — nothing in WeKan knows what is
behind it.
(`MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true`) — nothing in
WeKan knows what is behind it. See
[directConnection](#why-the-url-says-directconnectiontrue) for why that parameter
is not optional.
## Which one to use
@@ -91,6 +93,53 @@ merely setting `MONGO_OPLOG_URL` starts a tail regardless of the reactivity orde
FerretDB has no MongoDB change streams at all, in either version.
## Why the URL says `directConnection=true`
Every FerretDB v1 compose file connects with
`mongodb://ferretdb:27017/wekan?directConnection=true`, and dropping that
parameter breaks the stack on the first start
([#6582](https://github.com/wekan/wekan/issues/6582)):
```
MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
reason: TopologyDescription {
type: 'ReplicaSetNoPrimary',
servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
setName: 'rs0', ... }
```
`0.0.0.0` appears in no compose file. It is FerretDB's own **listen** address,
and the driver was handed it by the server.
Because the `ferretdb` service runs with `--repl-set-name=rs0` (see
[The OpLog](#the-oplog) above), FerretDB answers the `hello` handshake as a
one-member replica set, and fills the `hosts`, `me` and `primary` fields with its
`--listen-addr` — the wildcard `0.0.0.0:27017`. A MongoDB driver that is not in
direct-connection mode treats such a reply as an invitation to do replica-set
**discovery**: it adopts the member list the server advertised, and drops the
seed it was given, because the server reports a name other than the one that was
dialled. So `mongodb://ferretdb:27017` turns into `0.0.0.0:27017`, which inside
the `wekan-app` container means that container itself — where nothing is
listening. Hence `ECONNREFUSED`, on a compose file that was never edited.
`directConnection=true` tells the driver to stay on exactly the host it was
given and skip discovery entirely. Measured against FerretDB v1.49.0 with the
driver the bundle ships:
| MONGO\_URL | Topology the driver ends up with |
| --- | --- |
| `mongodb://localhost:27017/wekan` | `ReplicaSetWithPrimary`, servers: `0.0.0.0:27017` — the seed was discarded |
| `mongodb://localhost:27017/wekan?directConnection=true` | `Single`, servers: `localhost:27017` |
It costs nothing else. The handshake still reports `setName: 'rs0'`, and that is
the only thing Meteor checks before it will tail an OpLog, so
`MONGO_OPLOG_URL=...?replicaSet=rs0&directConnection=true` keeps working exactly
as [#6480/#6481](https://github.com/wekan/wekan/issues/6480) left it.
This applies only to FerretDB. The MongoDB compose files talk to a real replica
set whose members are configured with names that do resolve, so they keep plain
`replicaSet=rs0` and must **not** gain `directConnection`.
## Do they all answer the same?
[Conformance.md](Conformance.md) — `./build.sh` → Tests → **All databases
+6 -1
View File
@@ -2,7 +2,12 @@
FerretDB speaks the MongoDB wire protocol and stores the data in something else,
so WeKan runs unchanged on top of it — WeKan only ever connects to
`mongodb://ferretdb:27017/wekan` and knows nothing about what is behind it.
`mongodb://ferretdb:27017/wekan?directConnection=true` and knows nothing about
what is behind it. The `directConnection=true` is required, not decoration:
without it the driver follows FerretDB's replica-set handshake to the wildcard
address FerretDB listens on and the stack cannot start
([#6582](https://github.com/wekan/wekan/issues/6582), explained in
[1/README.md](1/README.md#why-the-url-says-directconnectiontrue)).
There are two versions, and they are different products:
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-10.80-win64.zip](https://github.com/wekan/wekan/releases/download/v10.80/wekan-10.80-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-10.83-win64.zip](https://github.com/wekan/wekan/releases/download/v10.83/wekan-10.83-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-10.80-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-10.83-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+3
View File
@@ -710,6 +710,7 @@
"initials": "Initials",
"invalid-date": "Invalid date",
"invalid-time": "Invalid time",
"invalid-year": "Invalid year. Please type all four digits, for example 2026.",
"invalid-user": "Invalid user",
"joined": "joined",
"just-invited": "You are just invited to this board",
@@ -1846,6 +1847,8 @@
"convert-to-markdown": "Convert to markdown",
"import-board-zip": "Add .zip file that has board JSON files, and board name subdirectories with attachments",
"collapse": "Collapse",
"collapse-checklist": "Collapse checklist",
"expand-checklist": "Expand checklist",
"uncollapse": "Uncollapse",
"hideCheckedChecklistItems": "Hide checked checklist items",
"hideAllChecklistItems": "Hide all checklist items",
+22
View File
@@ -46,6 +46,28 @@ runOnServer(function() {
return;
}
// GHSA-6p5m-f9p2-wqm5: the card has to BELONG to the board being
// authorised. Every check below is about `board` - isPublic() on one
// branch, canExport() on the other - and both were deciding access to one
// object while the export read a different one, named by a path parameter
// the caller also controls. Bound here, at the routing layer, as well as in
// the exporter's own query: the two identifiers arrive together, so this is
// where their relationship is cheapest to state, and it holds for the
// public branch too, which skips authentication entirely.
//
// 404, not 403: whether a given card id exists at all is not something an
// unauthorised caller should learn from the difference.
const containedCard = await ReactiveCache.getCard({
_id: paramCardId,
boardId,
listId: paramListId,
});
if (!containedCard) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Card not found');
return;
}
// Public boards skip authentication
if (board.isPublic()) {
const fieldsParam = req.query.fields;
+49 -1
View File
@@ -58,4 +58,52 @@ function expiredNotificationActivityIds(notifications, removeAgeDays, now) {
return ids;
}
module.exports = { expiredNotificationActivityIds };
// #6533: THE ARRAY ALSO HAS TO BE BOUNDED, and the rule above cannot do it.
// Only notifications that have been READ are ever removed, so a user who does
// not clear their tray accumulates entries with no upper limit - and they live
// in an array INSIDE the user document, `profile.notifications`.
//
// That is what shows up as a database problem rather than a tray problem. Every
// new notification is an `$addToSet` on that array, which means reading the whole
// user document, scanning the array for a duplicate, and writing the whole
// document back. FerretDB on SQLite has ONE writer, so those rewrites queue
// behind each other and start failing:
//
// [db-retry] the database was busy: ... 3 given up on
// ValidationError: ... [collection.go:191 sqlite.(*collection).UpdateAll]
// database is locked (5) (SQLITE_BUSY)
// at _helpersConstructor.addNotification (models/users.js)
//
// with FerretDB at 737% CPU. The work is proportional to the array, so it gets
// worse exactly as the array gets longer, and login - which writes to the user
// document - queues behind it.
//
// So the newest KEEP entries are kept and the rest dropped. Newest, not oldest:
// the tray shows recent activity, and an unread notification from last year is
// not something anyone is going to act on. Order is positional - `$addToSet`
// appends - so the tail is the newest.
//
// Returns the entries to KEEP, or null when nothing needs dropping (so the
// caller can skip the write entirely rather than rewrite the array with itself).
function cappedNotifications(notifications, maxPerUser) {
if (!Array.isArray(notifications)) return null;
const cap = Number.isInteger(maxPerUser) && maxPerUser > 0 ? maxPerUser : 0;
if (cap === 0 || notifications.length <= cap) return null;
return notifications.slice(notifications.length - cap);
}
// How many the tray keeps per user. Generous - this is a backstop against
// unbounded growth, not a retention policy - and overridable for an instance
// whose users really do want a longer history.
function notificationCapFromEnv(env) {
const raw = (env || {}).NOTIFICATION_TRAY_MAX_PER_USER;
const parsed = parseInt(raw, 10);
if (Number.isInteger(parsed) && parsed > 0) return parsed;
return 1000;
}
module.exports = {
expiredNotificationActivityIds,
cappedNotifications,
notificationCapFromEnv,
};
+110
View File
@@ -0,0 +1,110 @@
'use strict';
// Which template-container boards are safe to remove, and which are not.
//
// WHY THIS EXISTS. Before v10.00 every new account got a "Templates" container
// board created for it at signup, whether or not the person ever saved a
// template. That was removed in v10.00 (#2339, #5850) - the container is created
// lazily by `ensureTemplatesBoard` now, only when a template is actually saved -
// but nothing ever removed the ones already made, and they are not visible enough
// for anyone to delete by hand.
//
// On an instance that provisions accounts automatically the leftovers dominate
// the boards collection. One reported instance: 14490 boards, of which 13404 are
// template containers, for 9264 accounts of which 478 have ever logged in. Every
// query that touches boards - and under polling reactivity that is every poll -
// carries that 13x.
//
// WHAT IS SAFE TO REMOVE is the whole question, and the answer is deliberately
// narrow: a container that is EMPTY. If somebody ever saved a template into it,
// or renamed it, or invited anyone to it, it is theirs and it stays - the point
// is to remove boards that were created FOR people who never used them, not to
// tidy up after people who did.
//
// Nothing here deletes anything. It decides, and returns the decision with a
// reason attached, so the caller can show what WOULD go before anything does.
// A container is removable only when every one of these holds.
//
// type === 'template-container' it is a container, not a board or a template
// no template boards inside it nothing was ever saved into it
// no lists, swimlanes or cards nothing was put in it directly either
// exactly one member only the person it was made for
// not renamed still called whatever WeKan called it
// not starred / not archived nobody has expressed any interest in it
//
// `templateCount`, `listCount`, `swimlaneCount` and `cardCount` are supplied by
// the caller, which is what queries the database; this stays pure.
function classifyTemplateContainer(board, counts = {}, options = {}) {
const reasons = [];
if (!board || typeof board !== 'object') {
return { removable: false, reasons: ['not a board'] };
}
if (board.type !== 'template-container') {
return { removable: false, reasons: ['not a template container'] };
}
const {
templateCount = 0,
listCount = 0,
swimlaneCount = 0,
cardCount = 0,
} = counts;
if (templateCount > 0) reasons.push(`holds ${templateCount} template(s)`);
if (cardCount > 0) reasons.push(`holds ${cardCount} card(s)`);
if (listCount > 0) reasons.push(`holds ${listCount} list(s)`);
if (swimlaneCount > 0) reasons.push(`holds ${swimlaneCount} swimlane(s)`);
const members = Array.isArray(board.members) ? board.members : [];
if (members.length > 1) {
reasons.push(`shared with ${members.length} members`);
}
if (board.archived) reasons.push('archived by hand');
if (board.starred) reasons.push('starred');
// Renamed = somebody made it theirs. The default title is whatever the
// creating code used; the caller passes the set of titles that count as
// untouched, because that string is translated and has changed over time.
const defaultTitles = options.defaultTitles;
if (Array.isArray(defaultTitles) && defaultTitles.length > 0) {
const title = typeof board.title === 'string' ? board.title.trim() : '';
if (title && !defaultTitles.includes(title)) {
reasons.push(`renamed to "${title}"`);
}
}
return { removable: reasons.length === 0, reasons };
}
// The selector for candidate containers. Deliberately only narrows by TYPE: what
// makes one removable needs counts from four other collections, so the caller
// walks these and asks classifyTemplateContainer about each.
function templateContainersSelector() {
return { type: 'template-container' };
}
// Split a classified list into what would go and what would stay, so a caller
// can report both without repeating the rule.
function planTemplateContainerCleanup(entries) {
const remove = [];
const keep = [];
for (const entry of Array.isArray(entries) ? entries : []) {
if (!entry || !entry.board) continue;
const verdict = classifyTemplateContainer(
entry.board, entry.counts, entry.options);
(verdict.removable ? remove : keep).push({
boardId: entry.board._id,
title: entry.board.title,
reasons: verdict.reasons,
});
}
return { remove, keep };
}
module.exports = {
classifyTemplateContainer,
templateContainersSelector,
planTemplateContainerCleanup,
};
+26 -1
View File
@@ -210,7 +210,32 @@ class ExporterExcelCard {
// ── Internal build ───────────────────────────────────────────────────────
async _buildAndWrite(res) {
const card = await ReactiveCache.getCard(this._cardId);
// GHSA-6p5m-f9p2-wqm5: the card must be looked up INSIDE the board and list
// the caller was authorised for, not by its id alone.
//
// canExport() above answers "may this user see the board in :boardId?" - it
// never sees :cardId. So with a bare primary-key lookup here the two
// identifiers came apart: :boardId decided the authorisation and :cardId
// decided the data. Any authenticated user could create their own public
// board (POST /api/boards takes `permission` straight from the body), name
// it as :boardId, and pass the id of a card in somebody's private board as
// :cardId. The export then returned that card's title, description, members,
// every comment with its author, checklists, subtasks, attachment metadata -
// and, because image attachments are read through getReadStream() and
// embedded in the workbook further down, the attachment BYTES.
//
// The same route shape for PDF has always been right
// (ExporterCardPDF._getCardData: getCard({ _id, boardId, listId })), which is
// what makes this an omission rather than a decision. Constraining the query
// is the fix rather than a check bolted on after it: a card outside the
// authorised board now does not resolve at all, so nothing downstream - the
// checklist, subtask, comment and attachment fan-out, all keyed on the same
// card id - can read anything either.
const card = await ReactiveCache.getCard({
_id: this._cardId,
boardId: this._boardId,
listId: this._listId,
});
if (!card) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Card not found');
+42
View File
@@ -850,6 +850,27 @@ Users.attachSchema(
defaultValue: {},
blackbox: true,
},
'profile.collapsedCardSections': {
/**
* Per-user collapsed state for the sections of a card (#1591).
* profile[cardId][sectionKey] = true|false
*
* ONE map for every foldable thing on a card, because they are all the
* same question: a whole feature group ('labels', 'members', 'due', ...)
* uses the section's own name, and an individual checklist uses
* 'checklist-<checklistId>'. The same key works on the opened card and on
* the minicard, so folding a checklist folds it in both.
*
* Per USER, like collapsedLists and collapsedSwimlanes above, and unlike
* the checklist's own hideAllChecklistItems, which is a field on the
* checklist and therefore changes what EVERYONE on the board sees.
* Folding something to get it out of your way is a view preference, not
* an edit to the card.
*/
type: Object,
defaultValue: {},
blackbox: true,
},
'profile.keyboardShortcuts': {
/**
* User-specified state of keyboard shortcut activation.
@@ -1935,6 +1956,20 @@ Users.helpers({
}
return null;
},
/** #1591: null means "never set", so the caller can apply its own default
* (expanded) instead of a stored false being indistinguishable from absent. */
getCollapsedCardSection(cardId, sectionKey) {
const { collapsedCardSections = {} } = this.profile || {};
if (collapsedCardSections[cardId] &&
typeof collapsedCardSections[cardId][sectionKey] === 'boolean') {
return collapsedCardSections[cardId][sectionKey];
}
return null;
},
/** The key for one checklist, so the opened card and the minicard agree. */
checklistSectionKey(checklistId) {
return `checklist-${checklistId}`;
},
setCollapsedListToStorage(boardId, listId, collapsed) {
// Logged-in users: save to profile
if (this._id) {
@@ -2319,6 +2354,13 @@ Users.helpers({
return await Users.updateAsync(this._id, { $set: { 'profile.collapsedSwimlanes': current } });
},
async setCollapsedCardSection(cardId, sectionKey, collapsed) {
const current = (this.profile && this.profile.collapsedCardSections) || {};
if (!current[cardId]) current[cardId] = {};
current[cardId][sectionKey] = !!collapsed;
return await Users.updateAsync(this._id, { $set: { 'profile.collapsedCardSections': current } });
},
async setMobileMode(enabled) {
return await Users.updateAsync(this._id, { $set: { 'profile.mobileMode': enabled } });
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v10.80.0",
"version": "v10.83.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v10.80.0",
"version": "v10.83.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v10.80.0",
"version": "v10.83.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v10.80</title>
<title>WeKan REST API v10.83</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>(<!-- -->v10.80<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.83<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -2005,7 +2005,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":"v10.80","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 Line truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.83","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 Line truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+19 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v10.80
version: v10.83
description: |
The REST API allows you to control and extend Wekan with ease.
@@ -6800,6 +6800,23 @@ definitions:
Per-user collapsed state for swimlanes.
profile[boardId][swimlaneId] = true|false
type: object
collapsedCardSections:
description: |
Per-user collapsed state for the sections of a card (#1591).
profile[cardId][sectionKey] = true|false
ONE map for every foldable thing on a card, because they are all the
same question: a whole feature group ('labels', 'members', 'due', ...)
uses the section's own name, and an individual checklist uses
'checklist-<checklistId>'. The same key works on the opened card and on
the minicard, so folding a checklist folds it in both.
Per USER, like collapsedLists and collapsedSwimlanes above, and unlike
the checklist's own hideAllChecklistItems, which is a field on the
checklist and therefore changes what EVERYONE on the board sees.
Folding something to get it out of your way is a view preference, not
an edit to the card.
type: object
keyboardShortcuts:
description: |
User-specified state of keyboard shortcut activation.
@@ -6843,6 +6860,7 @@ definitions:
- swimlaneHeights
- collapsedLists
- collapsedSwimlanes
- collapsedCardSections
- keyboardShortcuts
- verticalScrollbars
- showWeekOfYear
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env bash
#
# ── Catch the Helm chart repo up with the WeKan releases that exist ──────────
#
# The wekan/charts gh-pages index lists one chart per WeKan release. It is
# written one entry at a time by releases/release-charts.sh, during a release -
# so a release whose `charts` job did not run leaves a hole, and the hole is
# permanent: the next release adds ITS entry and never looks back. v10.80 is one
# (its charts job was skipped when a cancelled build-mac-x64 cancelled the run),
# and it is not the only one - 474 of the 690 WeKan releases have no chart entry.
#
# This script closes the gap from the outside, and is the answer to "which WeKan
# releases should the index list?": the ones that EXIST AND CAN BE INSTALLED.
#
# keep - a chart package is already published for it. Never rebuilt: the
# published .tgz and its digest are what people's helm clients have
# already seen, and repackaging changes the digest for no reason.
# build - the release exists, its container image exists, and no chart is
# published. This is the hole to fill.
# omit - the release exists but its container image DOES NOT. A chart is a
# pointer to an image (`tag: v<version>`), so an entry for a version
# with no image is an install that fails at the pull. Those releases
# are LEFT OUT of the index rather than listed and broken - most are
# releases whose own docker job failed, which is exactly why there is
# no chart for them either.
#
# The index is then REBUILT from the .tgz files that are actually in the
# directory (`helm repo index --merge`), so "omitted" needs no bookkeeping: a
# version with no package is simply not in the index, and an entry can never
# point at a package that is not there.
#
# Usage:
# ./releases/backfill-charts.sh # PLAN ONLY - prints what it would do
# ./releases/backfill-charts.sh --apply # package the missing charts, rebuild index.yaml
# ./releases/backfill-charts.sh --apply --push
#
# Without --apply nothing is written, and the plan needs no helm - only curl and
# python3 - so it can be read anywhere. --apply needs helm and a charts checkout,
# and even then it does NOT push unless --push is given: rewriting a live Helm
# repository's index is the maintainer's call, not a side effect of a dry run.
#
# CHARTS_DIR points at the wekan/charts checkout, as in release-charts.sh; it
# defaults to ../w/charts beside this repo.
set -euo pipefail
APPLY=false
PUSH=false
LIMIT=0
for arg in "$@"; do
case "$arg" in
--apply) APPLY=true ;;
--push) PUSH=true ;;
--limit=*) LIMIT="${arg#--limit=}" ;;
-h|--help)
sed -n '2,45p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*)
echo "backfill-charts.sh: unknown argument '$arg'" >&2
exit 1 ;;
esac
done
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CHART_BASE_URL="https://wekan.github.io/charts"
# .tools/charts first: companion repositories are kept under .tools/ (CLAUDE.md),
# which is where the wekan/charts clone lives. ../w/charts stays as the older
# location, so an existing checkout there keeps working.
if [ -z "${CHARTS_DIR:-}" ]; then
for candidate in "$REPO_DIR/.tools/charts" "$REPO_DIR/../w/charts"; do
if [ -d "$candidate" ]; then
CHARTS_DIR="$(cd "$candidate" && pwd)"
break
fi
done
fi
if [ -z "${CHARTS_DIR:-}" ]; then
echo "Charts repo not found at $REPO_DIR/.tools/charts or $REPO_DIR/../w/charts."
echo "Set CHARTS_DIR to the wekan/charts checkout, or run with no --apply for a plan."
[ "$APPLY" = true ] && exit 1
fi
# ── 1. The plan ──────────────────────────────────────────────────────────────
#
# Three questions, all answered over the network so the plan is about what is
# really published rather than what this checkout happens to contain:
# which WeKan releases exist - the GitHub releases API
# which charts are published - the live index.yaml
# which container images exist - ghcr.io's manifest endpoint
#
# The image check is the slow one (one request per release without a chart), so
# it is the only one that is concurrent, and a release is only asked about when
# its answer can change the outcome.
PLAN_JSON="$(mktemp)"
trap 'rm -f "$PLAN_JSON"' EXIT
CHART_BASE_URL="$CHART_BASE_URL" LIMIT="$LIMIT" python3 - "$PLAN_JSON" <<'PYEOF'
import json, os, re, sys, urllib.error, urllib.request
from concurrent.futures import ThreadPoolExecutor
out_path = sys.argv[1]
base_url = os.environ["CHART_BASE_URL"]
limit = int(os.environ.get("LIMIT") or 0)
def get(url, headers=None, timeout=60):
req = urllib.request.Request(url, headers=headers or {})
return urllib.request.urlopen(req, timeout=timeout)
# Every WeKan release, newest first. Only vNN.MM tags: those are the releases a
# chart is ever cut for.
releases = []
for page in range(1, 20):
with get(f"https://api.github.com/repos/wekan/wekan/releases?per_page=100&page={page}") as r:
batch = json.load(r)
if not batch:
break
releases += batch
versions = []
for rel in releases:
m = re.fullmatch(r"v(\d+)\.(\d+)", rel.get("tag_name", ""))
if m:
versions.append((int(m.group(1)), int(m.group(2))))
versions = sorted(set(versions), reverse=True)
print(f"WeKan releases: {len(versions)}", file=sys.stderr)
# The charts already published, read from the live index.
try:
with get(f"{base_url}/index.yaml") as r:
index_text = r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
sys.exit(f"cannot read {base_url}/index.yaml: HTTP {e.code}")
# EXACTLY four spaces. An entry's own `version:` is at that indent; the mongodb
# subchart's `version:` under `dependencies:` is at six, and a `\s*` here counts
# it as a published chart (0.7.2, 220 times over).
published = set(re.findall(r"^ {4}version:\s*(\d+\.\d+\.\d+)\s*$", index_text, re.M))
print(f"charts published: {len(published)}", file=sys.stderr)
# ghcr.io serves public manifests with an anonymous pull token.
with get("https://ghcr.io/token?scope=repository:wekan/wekan:pull&service=ghcr.io") as r:
token = json.load(r)["token"]
ACCEPT = ",".join([
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v2+json",
])
def image_exists(tag):
req = urllib.request.Request(
f"https://ghcr.io/v2/wekan/wekan/manifests/{tag}",
headers={"Authorization": f"Bearer {token}", "Accept": ACCEPT},
method="HEAD")
try:
with urllib.request.urlopen(req, timeout=60) as r:
return r.status == 200
except urllib.error.HTTPError:
return False
except Exception:
# A transport hiccup must not be read as "no image": that would OMIT a
# release that has one. Unknown is reported and the release is left as
# it is, which for a release with no chart means no chart this run.
return None
keep, candidates = [], []
for major, minor in versions:
ver = f"{major}.{minor:02d}"
if f"{ver}.0" in published:
keep.append(ver)
else:
candidates.append(ver)
if limit:
candidates = candidates[:limit]
print(f"asking ghcr about {len(candidates)} releases with no chart ...", file=sys.stderr)
with ThreadPoolExecutor(max_workers=16) as ex:
have_image = list(ex.map(lambda v: image_exists(f"v{v}"), candidates))
build = [v for v, ok in zip(candidates, have_image) if ok is True]
omit = [v for v, ok in zip(candidates, have_image) if ok is False]
unknown = [v for v, ok in zip(candidates, have_image) if ok is None]
with open(out_path, "w", encoding="utf-8") as fh:
json.dump({"keep": keep, "build": build, "omit": omit, "unknown": unknown}, fh)
PYEOF
KEEP_N=$(jq '.keep | length' "$PLAN_JSON")
BUILD_N=$(jq '.build | length' "$PLAN_JSON")
OMIT_N=$(jq '.omit | length' "$PLAN_JSON")
UNKNOWN_N=$(jq '.unknown | length' "$PLAN_JSON")
echo
echo "=== Helm chart backfill plan ==="
printf ' keep %-5s chart already published; not rebuilt\n' "$KEEP_N"
printf ' build %-5s release and image exist, no chart yet\n' "$BUILD_N"
printf ' omit %-5s release exists but has NO container image, so a chart for it\n' "$OMIT_N"
printf ' would install and then fail at the image pull\n'
[ "$UNKNOWN_N" != "0" ] && printf ' ? %-5s ghcr did not answer; left alone this run\n' "$UNKNOWN_N"
echo
echo " to build:"
jq -r '.build[]' "$PLAN_JSON" | head -40 | sed 's/^/ /'
[ "$BUILD_N" -gt 40 ] && echo " ... and $((BUILD_N - 40)) more"
echo " omitted (no image):"
jq -r '.omit[]' "$PLAN_JSON" | head -20 | sed 's/^/ /'
[ "$OMIT_N" -gt 20 ] && echo " ... and $((OMIT_N - 20)) more"
if [ "$APPLY" != true ]; then
echo
echo "Plan only. Nothing was written. Re-run with --apply to package the ${BUILD_N} missing"
echo "charts and rebuild index.yaml, and --apply --push to publish them."
exit 0
fi
# ── 2. Apply ─────────────────────────────────────────────────────────────────
command -v helm >/dev/null 2>&1 || { echo "backfill-charts.sh: --apply needs helm." >&2; exit 1; }
[ -d "${CHARTS_DIR:-}/wekan" ] || { echo "backfill-charts.sh: no chart source at ${CHARTS_DIR:-<unset>}/wekan." >&2; exit 1; }
sedi() { if [ "$(uname)" = "Darwin" ]; then sed -i '' "$@"; else sed -i "$@"; fi; }
cd "$CHARTS_DIR"
# The chart SOURCE is on main; the packages and the index live on gh-pages. Both
# are needed, so the source is copied out before the branch is switched.
git checkout main
git pull --ff-only || true
SRC="$(mktemp -d)"
trap 'rm -f "$PLAN_JSON"; rm -rf "$SRC"' EXIT
cp -a wekan "$SRC/wekan"
git checkout gh-pages
git pull --ff-only || true
# Each missing version is packaged from the CURRENT chart templates with its own
# version numbers - which is all release-charts.sh ever varies between releases
# anyway (appVersion, version, and the image tag; dependency versions are not
# touched). A backfilled chart is therefore the chart as it is today, pointed at
# that release's image, which is the only thing that can be reconstructed after
# the fact and the only thing the version in the index means.
built=0
for VERSION in $(jq -r '.build[]' "$PLAN_JSON"); do
CHART_VERSION="${VERSION}.0"
if [ -f "wekan-${CHART_VERSION}.tgz" ]; then
continue
fi
work="$(mktemp -d)"
cp -a "$SRC/wekan" "$work/wekan"
sedi -E "s|^appVersion: \"[^\"]*\"|appVersion: \"${VERSION}\"|" "$work/wekan/Chart.yaml"
sedi -E "s|^version: [0-9]+\.[0-9]+\.[0-9]+|version: ${CHART_VERSION}|" "$work/wekan/Chart.yaml"
sedi -E "s|tag: v[0-9]+\.[0-9]+(\.[0-9]+)?|tag: v${VERSION}|" "$work/wekan/values.yaml"
( cd "$work" && helm dependency build wekan >/dev/null 2>&1 || helm dependency update wekan >/dev/null 2>&1 || true )
if helm package "$work/wekan" --destination . >/dev/null; then
built=$((built + 1))
echo " packaged wekan-${CHART_VERSION}.tgz"
else
echo "::warning::could not package a chart for ${VERSION}; it stays out of the index."
fi
rm -rf "$work"
done
echo "packaged ${built} chart(s)."
# --merge keeps every entry that is already there, with its original `created`
# and digest, and adds one for each package that is new. A version with no .tgz
# in this directory gets no entry, which is how "omit" is enforced: not by a
# list, but by there being nothing to index.
helm repo index . --merge index.yaml --url "$CHART_BASE_URL"
# --merge preserves what is already there, INCLUDING entries that should never
# have been there twice. The live index has four entries for 9.36.0 and two for
# 10.30.0, each with a DIFFERENT digest and the SAME url - written by
# release-charts.sh, which prepends an entry every time it runs, so re-running a
# release duplicated its version instead of replacing it. At most one of those
# digests can be the digest of the wekan-9.36.0.tgz that is actually served, and
# a helm client that picks one of the others fails the integrity check on a file
# that is perfectly fine. So: for any version with more than one entry, keep the
# one whose digest matches the package on disk, and if none does, the newest.
python3 - <<'PYEOF'
import hashlib, os, re, sys
from collections import defaultdict
path = "index.yaml"
lines = open(path, encoding="utf-8").readlines()
hdr = next((i for i, ln in enumerate(lines) if ln.rstrip("\n") == " wekan:"), None)
if hdr is None:
sys.exit("index.yaml has no ' wekan:' entries block")
# Split the block into entries: each starts at a " - " line and runs until the
# next one, or until a line that dedents out of the block.
starts = []
end_of_block = len(lines)
for i in range(hdr + 1, len(lines)):
ln = lines[i]
if ln.startswith(" - "):
starts.append(i)
elif ln.strip() and not ln.startswith(" "):
end_of_block = i
break
bounds = [(s, (starts[k + 1] if k + 1 < len(starts) else end_of_block))
for k, s in enumerate(starts)]
def field(block, key):
for ln in block:
m = re.match(r"^ {4}" + re.escape(key) + r":\s*\"?([^\"\n]*)\"?\s*$", ln)
if m:
return m.group(1).strip()
return None
entries = []
for a, b in bounds:
block = lines[a:b]
entries.append({"block": block,
"version": field(block, "version"),
"digest": field(block, "digest"),
"created": field(block, "created") or ""})
by_version = defaultdict(list)
for e in entries:
by_version[e["version"]].append(e)
def real_digest(version):
tgz = f"wekan-{version}.tgz"
if not os.path.exists(tgz):
return None
h = hashlib.sha256()
with open(tgz, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
kept, dropped = [], []
for e in entries:
dupes = by_version[e["version"]]
if len(dupes) == 1:
kept.append(e)
continue
want = real_digest(e["version"])
if want is not None:
winner = next((d for d in dupes if d["digest"] == want), None)
else:
winner = None
if winner is None:
winner = max(dupes, key=lambda d: d["created"])
(kept if e is winner else dropped).append(e)
if dropped:
out = lines[:hdr + 1]
for e in entries:
if e in kept:
out += e["block"]
out += lines[end_of_block:]
open(path, "w", encoding="utf-8").write("".join(out))
for e in dropped:
print(f" dropped a duplicate entry for {e['version']} "
f"(digest {e['digest'][:12]}..., created {e['created']})")
print(f"index.yaml: {len(kept)} entries, {len(dropped)} duplicate(s) removed.")
PYEOF
if [ "$PUSH" != true ]; then
echo
echo "Not pushed (--push not given). Review with: git -C \"$CHARTS_DIR\" status"
exit 0
fi
git add -A
git commit -m "Backfill the chart index for the WeKan releases that have an image." || {
echo "Nothing to commit."; exit 0; }
git push origin gh-pages
echo "=== gh-pages pushed ==="
+43 -1
View File
@@ -72,10 +72,52 @@ have() { curl -fsSLI -o /dev/null --retry 3 --retry-delay 5 "$1"; }
# Without this the job dies 400 lines in, with docker saying "no matching
# manifest for linux/386 in the manifest list entries" - which is true of
# ubuntu:26.04 and was how the i386 and loong64 jobs failed.
#
# The VARIANT is part of the platform, and leaving it out is not a near miss: on
# 32-bit ARM it turns a clean "this image does not exist" into a bundle built in
# the WRONG userland. `docker manifest inspect debian:trixie` lists arm/v5 and
# arm/v7 and no arm/v6 - Debian has no ARMv6 port - but an architecture-only
# grep for "arm" matches, so the check passed and `docker run --platform
# linux/arm/v6` then resolved DOWNWARDS to arm/v5, Debian armel: ARMv5,
# SOFT-float, with no /lib/ld-linux-armhf.so.3 in it. The hard-float node-armv6
# cannot start there, and that is how the armv6 job died in v10.80:
#
# qemu-arm: Could not open '/lib/ld-linux-armhf.so.3': No such file or directory
#
# 400 lines in, after the whole apt install. Comparing architecture AND variant
# is what makes "publishes no linux/arm/v6" the answer at the top instead.
if [ -n "$image" ] && [ -n "$platform" ]; then
want_arch="$(printf '%s' "$platform" | cut -d/ -f2)"
want_variant="$(printf '%s' "$platform" | cut -d/ -f3-)"
if docker manifest inspect "$image" >/tmp/manifest.json 2>/dev/null; then
if ! grep -q "\"architecture\": *\"${want_arch}\"" /tmp/manifest.json; then
if ! WANT_ARCH="$want_arch" WANT_VARIANT="$want_variant" python3 - <<'PYEOF'
import json, os, sys
want_arch = os.environ["WANT_ARCH"]
want_variant = os.environ["WANT_VARIANT"]
with open("/tmp/manifest.json", encoding="utf-8") as fh:
doc = json.load(fh)
# A single-platform image has no "manifests" list; its own config names the
# architecture. Both shapes are answered the same way.
if "manifests" in doc:
plats = [m.get("platform", {}) for m in doc["manifests"]]
else:
plats = [{"architecture": doc.get("architecture", ""),
"variant": doc.get("variant", "")}]
for p in plats:
if p.get("architecture") != want_arch:
continue
# arm64 is published as arm64/v8 and everyone writes it linux/arm64, so an
# unrequested variant matches anything; a REQUESTED one must be exact,
# because arm/v6 and arm/v5 are different userlands, not near neighbours.
if not want_variant or p.get("variant", "") == want_variant:
sys.exit(0)
sys.exit(1)
PYEOF
then
if [ "$optional" = "true" ]; then
# A best-effort arch with no base image for its own CPU (loong64:
# node-loong64 and ferretdb-loong64 exist, but no debian/ubuntu
+17 -4
View File
@@ -186,6 +186,19 @@ skipped=0
# WEKAN_CONFORMANCE_PORT FerretDB itself (default 37017)
# WEKAN_CONFORMANCE_DB_PORT the database server (default 35432)
#
# There is a THIRD port, and it is the one that bit: FerretDB also opens a debug
# handler for metrics and profiling, at 127.0.0.1:8088 by default, and it EXITS
# when that address is taken. Nothing here uses it, but every backend died with
#
# Failed to create debug handler ... listen tcp 127.0.0.1:8088: bind: address
# already in use
# ERROR sqlite FerretDB did not start on this backend
#
# because an unrelated FerretDB was running on the machine. Choosing a free port
# for it would work; not opening it at all is better, since the run never asks it
# anything - so every launch below passes `--debug-addr=-`, which is how
# FerretDB's main.go spells "no debug handler".
#
# is_free: a port nothing is listening on. bash's /dev/tcp needs no extra tools;
# a refused connection means free.
is_free() {
@@ -385,16 +398,16 @@ for entry in "${BACKENDS[@]}"; do
case "$handler" in
sqlite) "$FERRET_BIN" --handler=sqlite --sqlite-url="$url" \
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
postgresql) "$FERRET_BIN" --handler=postgresql --postgresql-url="$url" \
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
mysql) "$FERRET_BIN" --handler=mysql --mysql-url="$url" \
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
hana) "$FERRET_BIN" --handler=hana --hana-url="$url" \
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
esac
FERRET_PID=$!
+13 -1
View File
@@ -177,8 +177,20 @@ chmod +x /bundle/node
# by bundle/cpu-exec to emulate CPU features a binary needs but the host CPU
# lacks. Tolerant: if the package is unavailable on this arch, the bundle just
# ships without it - cpu-exec then falls back to a system qemu-user, or reports.
#
# It is the ONE thing here taken from the CONTAINER rather than downloaded built
# for the target, so it is the one thing a container that is not the target's own
# CPU must not contribute. SAME_ARCH_CONTAINER=false says this is such a build:
# the armv6 bundle is assembled in Debian's arm/v7 (armhf) container, because
# Debian publishes no ARMv6 port and the armel one cannot run a hard-float node.
# There `uname -m` is armv7l, so this would drop an ARMv7 qemu-armv7l into an
# ARMv6 bundle - a binary a Raspberry Pi 1 cannot execute, under a name its
# cpu-exec (which looks for qemu-armv6l) would never ask for anyway. Ship
# without it and let cpu-exec fall back, which is what it is written to do.
rm -f /bundle/qemu-x86_64
if apt-get install -y -q qemu-user-static >/dev/null 2>&1 &&
if [ "${SAME_ARCH_CONTAINER:-true}" != "true" ]; then
echo "Bundle ${ARCH} is built in a $(uname -m) container that is not its own CPU; no qemu-user is bundled (cpu-exec falls back to the system one)."
elif apt-get install -y -q qemu-user-static >/dev/null 2>&1 &&
[ -x "/usr/bin/qemu-$(uname -m)-static" ]; then
cp "/usr/bin/qemu-$(uname -m)-static" "/bundle/qemu-$(uname -m)"
chmod +x "/bundle/qemu-$(uname -m)"
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""Rebuild the wekan/charts Helm index from the packages it actually serves.
An index.yaml is a description of the .tgz files sitting beside it, and this
regenerates it from those files instead of editing it in place. That matters
because the index had drifted from them in four separate ways, none of which an
in-place edit can notice:
MISSING ENTRIES. 145 packages on gh-pages had no entry at all - they are
downloadable but no `helm search`, no `helm install wekan --version 9.63.0`
and no Artifact Hub listing can find them. release-charts.sh writes one
entry per release, so a release whose charts job did not run leaves a hole,
and nothing ever goes back for it.
DUPLICATE ENTRIES WITH STALE DIGESTS. 9.36.0 appeared four times and 10.30.0
twice, each copy with a DIFFERENT digest and the SAME url, because
release-charts.sh PREPENDS an entry every time it runs and a re-run of a
release duplicated the version instead of replacing it. Only one of those
digests is the digest of the file being served; a helm client that picks
another fails the integrity check on a perfectly good package.
A WRONG appVersion, ON EVERY ENTRY. The index said appVersion "10.79.0" - the
CHART version - where the packaged Chart.yaml says "10.79", the WeKan
version. appVersion is what a user reads to see which WeKan they get.
FROZEN DEPENDENCY VERSIONS. Each new entry was copied from the previous one
with a few fields substituted, so fields nobody substituted never changed:
the index claimed the mongodb subchart was 0.7.2 for every release, while
the packages have moved on to 0.7.6.
Every one of those is the same mistake - treating the index as the record rather
than as a description of the packages - so the fix is to derive it from them.
`created` is preserved, never invented: an entry that already exists and whose
digest matches its package keeps the timestamp it has, and a package being added
takes the date it was COMMITTED to the gh-pages branch, which is when it really
was published (verified: wekan-10.79.0.tgz was committed at 2026-08-10T02:46:52Z
and its existing entry says created 2026-08-10T02:46:52.308983+00:00). Only a
package with no entry and no git history falls back to now.
Usage:
releases/reindex-charts.py [--charts-dir DIR] # report only, writes nothing
releases/reindex-charts.py --write [--charts-dir DIR]
DIR defaults to $CHARTS_DIR, then .tools/charts, then ../w/charts. It must be on
the gh-pages branch, where the packages and index.yaml live.
"""
import argparse
import hashlib
import io
import json
import os
import re
import subprocess
import sys
import tarfile
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
try:
import yaml
except ImportError:
sys.exit("reindex-charts.py needs PyYAML (pip install pyyaml, or apt install python3-yaml).")
CHART_NAME = "wekan"
BASE_URL = "https://wekan.github.io/charts"
def find_charts_dir(explicit):
"""Where the charts checkout is, in the order the repo keeps things."""
here = os.path.dirname(os.path.abspath(__file__))
repo = os.path.dirname(here)
for candidate in (explicit, os.environ.get("CHARTS_DIR"),
os.path.join(repo, ".tools", "charts"),
os.path.join(repo, "..", "w", "charts")):
if candidate and os.path.isdir(candidate):
return os.path.abspath(candidate)
return None
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def chart_metadata(path):
"""The packaged Chart.yaml - which IS the index entry, bar created/digest/urls.
Read from the archive rather than from the chart source, because the archive
is what is served: a source tree that has moved on since cannot make an entry
describe a package wrongly.
"""
with tarfile.open(path) as tar:
members = [m for m in tar.getnames()
if m.count("/") == 1 and m.endswith("/Chart.yaml")]
if not members:
return None
with tar.extractfile(members[0]) as fh:
return yaml.safe_load(fh.read())
def git_added(directory, filename):
"""When this package was committed to gh-pages - i.e. when it was published."""
try:
out = subprocess.run(
["git", "-C", directory, "log", "--diff-filter=A", "--format=%aI",
"-1", "--", filename],
capture_output=True, text=True, timeout=60, check=False).stdout.strip()
return out or None
except (OSError, subprocess.SubprocessError):
return None
def version_key(version):
parts = re.findall(r"\d+", version or "")
return [int(p) for p in parts] or [0]
# ── Charts that are published but cannot be installed ────────────────────────
#
# A chart is a pointer to container images, so a chart whose images have been
# deleted from their registries installs and then fails at the pull. Listing one
# is worse than not listing it: Artifact Hub scans every entry in the index for
# vulnerabilities and reports each unpullable image as an error against the
# repository -
#
# error scanning image ghcr.io/wekan/wekan:v9.62: image not found
# error scanning image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found
#
# - so the index says the repository is broken when the repository is fine and
# the images are gone.
#
# Two separate causes, both real here. Six WeKan images were never pushed or have
# since been removed (v8.30, v9.12, v9.14, v9.38, v9.39, v9.62 - the releases
# whose own docker job failed). And 129 older charts vendor the BITNAMI mongodb
# subchart, pinning tags such as bitnami/mongodb:7.0.14-debian-12-r3 that Bitnami
# has deleted; charts from 8.x on vendor groundhog2k's, which uses the official
# `mongo` image and is unaffected.
#
# The exclusion is RECORDED IN THE REPOSITORY, in UNINDEXED_FILE, rather than
# decided on every run. Two reasons. A rebuild during a release must not depend
# on reaching two registries - a network blip would silently drop half the index.
# And the decision is reversible and reviewable: the file says which package was
# left out and why, so if an image comes back, deleting a line puts the chart
# back in the index. `--check-images` is what regenerates it.
UNINDEXED_FILE = "unindexed.txt"
REGISTRY_ACCEPT = ",".join([
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.manifest.v1+json",
])
def split_image(registry, repository, tag):
"""Normalise an image reference to (host, repository, tag).
Older WeKan charts put the whole reference in `repository`
("ghcr.io/wekan/wekan") and leave `registry` unset, so the docker.io default
must not be believed when the repository itself begins with a host - reading
those as docker.io/ghcr.io/wekan/wekan is how a first pass came back
"unknown" for every one of them.
"""
head = repository.split("/")[0]
if "." in head or head == "localhost":
registry, repository = head, repository.split("/", 1)[1]
registry = registry or "docker.io"
if registry in ("docker.io", "index.docker.io", "registry-1.docker.io"):
host = "registry-1.docker.io"
if "/" not in repository:
repository = "library/" + repository
else:
host = registry
return host, repository, tag or "latest"
def image_exists(host, repository, tag, _tokens={}):
"""True / False / None, where None means "could not tell" - never "missing".
The auth handshake is the REGISTRY'S OWN, not a guess per host: ask for the
manifest with no credentials, and if the registry answers 401 it says in its
WWW-Authenticate header which realm to get a token from and for what scope.
Hard-coding one token URL per registry is what made a first version report
every quay.io image as missing - including quay.io/wekan/wekan:latest, which
plainly exists - because the wrong endpoint gave a token quay would not
accept and the manifest came back 404. A chart must never leave the index on
the strength of a bad guess about a registry's API.
A registry that refuses anonymous auth, or a request that times out, returns
None: the list is only ever allowed to say what is really gone.
"""
url = f"https://{host}/v2/{repository}/manifests/{tag}"
headers = {"Accept": REGISTRY_ACCEPT}
def ask(extra=None):
request = urllib.request.Request(url, headers={**headers, **(extra or {})},
method="HEAD")
with urllib.request.urlopen(request, timeout=60) as response:
return response.status
try:
cached = _tokens.get((host, repository))
if cached:
return ask({"Authorization": f"Bearer {cached}"}) == 200
return ask() == 200
except urllib.error.HTTPError as error:
if error.code == 404:
return False
if error.code != 401:
return None
challenge = error.headers.get("WWW-Authenticate", "")
except Exception:
return None
fields = dict(re.findall(r'(\w+)="([^"]*)"', challenge))
realm = fields.pop("realm", None)
if not realm:
return None
fields.setdefault("scope", f"repository:{repository}:pull")
try:
with urllib.request.urlopen(
f"{realm}?{urllib.parse.urlencode(fields)}", timeout=60) as response:
payload = json.load(response)
token = payload.get("token") or payload.get("access_token")
if not token:
return None
_tokens[(host, repository)] = token
return ask({"Authorization": f"Bearer {token}"}) == 200
except urllib.error.HTTPError as error:
return False if error.code == 404 else None
except Exception:
return None
def chart_images(path):
"""Every image the chart and its vendored subcharts pin."""
found = []
def from_values(values):
image = (values or {}).get("image")
if isinstance(image, dict) and image.get("repository"):
found.append(split_image(image.get("registry"), image["repository"],
str(image.get("tag") or "latest")))
with tarfile.open(path) as tar:
top = [m for m in tar.getnames()
if m.count("/") == 1 and m.endswith("/values.yaml")]
if top:
with tar.extractfile(top[0]) as fh:
from_values(yaml.safe_load(fh.read()))
for sub in [m for m in tar.getnames()
if "/charts/" in m and m.endswith(".tgz")]:
with tar.extractfile(sub) as fh:
blob = fh.read()
try:
with tarfile.open(fileobj=io.BytesIO(blob)) as subtar:
inner = [m for m in subtar.getnames()
if m.count("/") == 1 and m.endswith("/values.yaml")]
if inner:
with subtar.extractfile(inner[0]) as sfh:
from_values(yaml.safe_load(sfh.read()))
except (tarfile.TarError, yaml.YAMLError):
continue
return found
def read_unindexed(directory):
"""{filename: reason} - the packages this repository deliberately does not list."""
path = os.path.join(directory, UNINDEXED_FILE)
out = {}
if not os.path.exists(path):
return out
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
name, _, reason = line.partition("#")
out[name.strip()] = reason.strip()
return out
def check_images(directory, packages):
"""Probe every image every package pins; return {filename: reason} for the
ones with an image that is definitely gone."""
per_package, wanted = {}, set()
for filename in packages:
try:
images = chart_images(os.path.join(directory, filename))
except (tarfile.TarError, yaml.YAMLError):
continue
per_package[filename] = images
wanted.update(images)
print(f"checking {len(wanted)} distinct images from {len(per_package)} packages ...",
file=sys.stderr)
with ThreadPoolExecutor(max_workers=12) as pool:
verdicts = dict(zip(wanted, pool.map(lambda i: image_exists(*i), wanted)))
unknown = [i for i, ok in verdicts.items() if ok is None]
if unknown:
print(f" {len(unknown)} image(s) could not be checked; they are treated as "
f"PRESENT, never as missing", file=sys.stderr)
out = {}
for filename, images in per_package.items():
gone = [f"{h}/{r}:{t}" for h, r, t in images if verdicts.get((h, r, t)) is False]
if gone:
out[filename] = f"image not found: {', '.join(sorted(gone))}"
return out
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--charts-dir")
ap.add_argument("--write", action="store_true",
help="rewrite index.yaml (without this, nothing is written)")
ap.add_argument("--check-images", action="store_true",
help="probe every image the charts pin and refresh "
f"{UNINDEXED_FILE} from the result (needs network)")
args = ap.parse_args()
directory = find_charts_dir(args.charts_dir)
if not directory:
sys.exit("reindex-charts.py: no charts checkout found. Pass --charts-dir "
"or set CHARTS_DIR to a wekan/charts clone on its gh-pages branch.")
index_path = os.path.join(directory, "index.yaml")
packages = sorted(f for f in os.listdir(directory) if f.endswith(".tgz"))
if not packages:
sys.exit(f"reindex-charts.py: no .tgz packages in {directory} - is it on "
f"the gh-pages branch?")
# What the index says now. Missing or unreadable is not fatal: the packages
# are the source of truth and an index can be built from them alone.
old_entries = []
if os.path.exists(index_path):
with open(index_path, encoding="utf-8") as fh:
old = yaml.safe_load(fh) or {}
old_entries = (old.get("entries") or {}).get(CHART_NAME) or []
old_by_version = {}
for entry in old_entries:
old_by_version.setdefault(entry.get("version"), []).append(entry)
# Which packages are published but must not be listed, and why.
if args.check_images:
unindexed = check_images(directory, packages)
if args.write:
path = os.path.join(directory, UNINDEXED_FILE)
with open(path, "w", encoding="utf-8") as fh:
fh.write(
"# Packages that are published here but are NOT listed in\n"
"# index.yaml, because an image they pin no longer exists in its\n"
"# registry - so the chart would install and then fail at the\n"
"# pull, and Artifact Hub reports each one as a scan error\n"
"# against this repository.\n"
"#\n"
"# Regenerate with: releases/reindex-charts.py --check-images --write\n"
"# An image that comes back: delete its line and rebuild.\n"
"# The .tgz files themselves stay, so a direct URL keeps working.\n\n")
for name in sorted(unindexed, key=lambda f: version_key(f)):
fh.write(f"{name} # {unindexed[name]}\n")
print(f"wrote {path} - {len(unindexed)} package(s) not listed.")
else:
unindexed = read_unindexed(directory)
now = datetime.now(timezone.utc).isoformat()
entries, skipped, added, retimed, corrected = [], [], [], [], []
excluded = []
for filename in packages:
if filename in unindexed:
excluded.append(f"{filename}: {unindexed[filename]}")
continue
path = os.path.join(directory, filename)
meta = chart_metadata(path)
if not meta or not meta.get("version"):
skipped.append(f"{filename}: no <chart>/Chart.yaml inside it")
continue
entry = dict(meta)
digest = sha256(path)
entry["digest"] = digest
entry["urls"] = [f"{BASE_URL}/{filename}"]
# created: keep what the matching entry already says, so a rebuild does
# not restamp the whole repository; otherwise take the publish date from
# git; only then fall back to now.
previous = old_by_version.get(meta["version"], [])
match = next((e for e in previous if str(e.get("digest", "")).strip() == digest), None)
if match and match.get("created"):
entry["created"] = str(match["created"])
else:
when = git_added(directory, filename)
entry["created"] = when or now
if previous:
retimed.append(f"{meta['version']} ({filename}): "
f"{len(previous)} stale entr{'y' if len(previous) == 1 else 'ies'} "
f"replaced, created taken from {'git' if when else 'now'}")
else:
added.append(f"{meta['version']} ({filename}), published {entry['created'][:10]}")
if match:
for field in ("appVersion", "dependencies"):
if field in meta and match.get(field) != meta.get(field):
corrected.append(f"{meta['version']}: {field} "
f"{match.get(field)!r} -> {meta[field]!r}")
entries.append(entry)
# TWO PACKAGES CLAIMING ONE CHART VERSION. Two 2023 packaging slips do this:
# wekan-1.2.7.tgz contains a chart whose Chart.yaml says version 1.2.6, and
# wekan-6.96.tgz and wekan-6.9.6.tgz both say 6.9.6. `helm repo index` would
# list both, leaving `--version 6.9.6` to resolve to whichever entry came
# first - so the index would name a version that means two different files.
# The package whose FILENAME matches the version it declares is the one that
# was meant; a mismatch is the slip. The other file stays on the server, so a
# direct URL anyone already has keeps working - it is just not offered as a
# version. If neither filename matches, the one published first wins, because
# that is the one that may already have been installed from.
by_version = {}
for entry in entries:
by_version.setdefault(entry["version"], []).append(entry)
unindexed = []
for version, group in by_version.items():
if len(group) == 1:
continue
def preference(entry):
filename = entry["urls"][0].rsplit("/", 1)[-1]
return (filename != f"{CHART_NAME}-{version}.tgz", entry.get("created", ""))
group.sort(key=preference)
for loser in group[1:]:
entries.remove(loser)
unindexed.append(
f"{loser['urls'][0].rsplit('/', 1)[-1]}: its Chart.yaml declares "
f"version {version}, which "
f"{group[0]['urls'][0].rsplit('/', 1)[-1]} also declares - kept the "
f"one whose filename matches. The file is still served, just not "
f"offered as a version.")
entries.sort(key=lambda e: version_key(e.get("version")), reverse=True)
seen = {}
for entry in entries:
seen.setdefault(entry["version"], []).append(entry)
collisions = {v: len(e) for v, e in seen.items() if len(e) > 1}
print(f"charts dir : {directory}")
print(f"packages : {len(packages)}")
print(f"index entries : {len(old_entries)} before -> {len(entries)} after")
print(f" added : {len(added)} (a package that had no entry at all)")
# The duplicates are counted in the OLD index, not derived from the sizes:
# entries added and packages not offered move that difference too, and
# folding all three into one subtraction reported six duplicates where there
# were four.
print(f" de-duplicated : {len(old_entries) - len({e.get('version') for e in old_entries})}"
f" (stale repeat entries for a version already listed)")
print(f" corrected : {len(corrected)} (fields that disagreed with the package)")
if excluded:
print(f" not listed : {len(excluded)} (an image they pin is gone - "
f"see {UNINDEXED_FILE})")
for line in excluded[:4]:
print(f" {line}")
if len(excluded) > 4:
print(f" ... and {len(excluded) - 4} more")
if skipped:
print(f" skipped : {len(skipped)}")
for line in skipped:
print(f" {line}")
if unindexed:
print(f" not offered : {len(unindexed)} (a version two packages claim)")
for line in unindexed:
print(f" {line}")
for line in retimed:
print(f" ! {line}")
for line in corrected[:6]:
print(f" ~ {line}")
if len(corrected) > 6:
print(f" ~ ... and {len(corrected) - 6} more")
for line in added[-8:]:
print(f" + {line}")
if len(added) > 8:
print(f" + ... and {len(added) - 8} older ones")
if collisions:
sys.exit(f"reindex-charts.py: two packages claim the same chart version "
f"{collisions} - refusing to write an index that cannot say which "
f"file a version is.")
if not args.write:
print("\nReport only. Nothing was written. Re-run with --write to rebuild index.yaml.")
return 0
document = {
"apiVersion": "v1",
"entries": {CHART_NAME: entries},
"generated": now,
}
with open(index_path, "w", encoding="utf-8") as fh:
yaml.safe_dump(document, fh, default_flow_style=False, width=10000,
sort_keys=True, allow_unicode=True)
print(f"\nWrote {index_path} - {len(entries)} entries.")
print("Review it (git diff), then commit and push the gh-pages branch.")
return 0
if __name__ == "__main__":
sys.exit(main())
+128 -93
View File
@@ -1,27 +1,36 @@
#!/bin/bash
# Update and publish the WeKan Helm chart in the separate wekan/charts repo,
# which lives at ../w/charts (a sibling of the wekan repo).
# checked out at .tools/charts (or the older ../w/charts).
#
# Usage:
# ./releases/release-charts.sh 9.36
#
# Called automatically by ./releases/version.sh during a release, so both
# ./releases/release.sh and ./releases/release-all.sh keep the chart in sync.
# ./releases/release.sh and ./releases/release-all.sh keep the chart in sync,
# and by the charts jobs of release-all.yml and release-all-missing.yml.
#
# THE TWO BRANCHES. The charts repo keeps the chart SOURCE on `main` and the
# published packages plus index.yaml on `gh-pages`, and its own two scripts move
# between them: main's release.sh commits the source, tars `wekan/` into
# wekan-<version>.0.tgz, checks out gh-pages and drops the package there;
# gh-pages' release2.sh commits and pushes that branch and returns to main. This
# script drives both, and owns the index in between.
#
# What it does:
# 1. On the charts repo main branch, bump ONLY the WeKan version number in:
# ../w/charts/wekan/Chart.yaml - appVersion (e.g. "9.36")
# - version (e.g. 9.36.0)
# ../w/charts/wekan/values.yaml - WeKan image tag (e.g. tag: v9.36)
# <charts>/wekan/Chart.yaml - appVersion (e.g. "9.36")
# - version (e.g. 9.36.0)
# <charts>/wekan/values.yaml - WeKan image tag (e.g. tag: v9.36)
# Dependency versions (mongodb chart, mongodb image, etc.) are NOT changed.
# 2. Run ../w/charts/release.sh <version>: helm dependency build, commit+push
# main, package wekan-<version>.0.tgz, then `git checkout gh-pages` and move
# the .tgz into the gh-pages working tree.
# 3. Prepend a new chart entry to gh-pages index.yaml: current datetime, the
# sha256sum of wekan-<version>.0.tgz, and the new version/urls (the rest is
# copied from the previous newest entry).
# 4. Run ../w/charts/release2.sh <version>: commit+push the gh-pages branch.
# 2. Run <charts>/release.sh <version>: commit+push main, package
# wekan-<version>.0.tgz, then `git checkout gh-pages` and move the .tgz into
# the gh-pages working tree.
# 3. Check the package's own Chart.yaml agrees with its filename, then REBUILD
# index.yaml from every package on gh-pages (releases/reindex-charts.py) -
# rather than prepending a hand-edited copy of the previous entry, which is
# where all four of the index's defects came from.
# 4. Run <charts>/release2.sh <version>: commit+push the gh-pages branch.
set -euo pipefail
@@ -35,16 +44,24 @@ VERSION="$1" # e.g. 9.36 (WeKan version, no v prefix)
CHART_VERSION="${VERSION}.0" # e.g. 9.36.0 (chart SemVer + index version)
# The remote (GitHub Actions) flow sets CHARTS_DIR to the charts repo it checked
# out; otherwise resolve it as a sibling of the wekan repo: <repo>/../w/charts.
# out; otherwise it is resolved below.
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
export REPO_DIR
# .tools/charts first: companion repositories are cloned there (CLAUDE.md), which
# is where the wekan/charts checkout lives. ../w/charts stays as the older
# location so an existing checkout keeps working. The chart SOURCE is on the main
# branch, so `wekan/` is what says a checkout is really there - on gh-pages it is
# not, and a run from the wrong branch should say so rather than half-work.
if [ -z "${CHARTS_DIR:-}" ]; then
if [ ! -d "$REPO_DIR/../w/charts/wekan" ]; then
echo "Charts repo not found at $REPO_DIR/../w/charts/wekan, skipping chart release."
exit 0
fi
CHARTS_DIR="$(cd "$REPO_DIR/../w/charts" && pwd)"
elif [ ! -d "$CHARTS_DIR/wekan" ]; then
echo "Charts repo not found at $CHARTS_DIR/wekan, skipping chart release."
for candidate in "$REPO_DIR/.tools/charts" "$REPO_DIR/../w/charts"; do
if [ -d "$candidate" ]; then
CHARTS_DIR="$(cd "$candidate" && pwd)"
break
fi
done
fi
if [ -z "${CHARTS_DIR:-}" ]; then
echo "Charts repo not found at $REPO_DIR/.tools/charts or $REPO_DIR/../w/charts, skipping chart release."
exit 0
fi
@@ -61,6 +78,50 @@ CHART_VALUES="$CHARTS_DIR/wekan/values.yaml"
echo "=== Helm chart release: WeKan v$VERSION (chart $CHART_VERSION) ==="
# ── 0. Is there an image for this version to point AT? ───────────────────────
#
# A chart is a pointer to a container image, so publishing one for a version
# whose image was never pushed produces a chart that installs and then fails at
# the pull - and Artifact Hub, which scans every entry in the index, reports it
# as an error against the whole repository:
#
# error scanning image ghcr.io/wekan/wekan:v9.62: image not found
#
# Six versions are in that state already (v8.30, v9.12, v9.14, v9.38, v9.39,
# v9.62 - releases whose own docker job failed), and they had to be taken back
# out of the index by hand. One request stops the seventh. In a full release the
# charts job runs after `docker` has pushed the image, so this passes; it is the
# out-of-band paths - release-all-missing.yml, and running this by hand - where a
# version can have no image at all.
#
# A registry that cannot be reached is NOT taken as "no image": the check has to
# be sure before it stops a release.
# PYTHONDONTWRITEBYTECODE: the check imports releases/reindex-charts.py, and
# importing a module writes releases/__pycache__ beside it - untracked junk
# appearing in the repository on every release.
if ! PYTHONDONTWRITEBYTECODE=1 python3 - "$VERSION" <<'PYEOF'
import importlib.util, os, sys
spec = importlib.util.spec_from_file_location(
"reindex", os.path.join(os.environ["REPO_DIR"], "releases", "reindex-charts.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
host, repo, tag = mod.split_image(None, "ghcr.io/wekan/wekan", "v" + sys.argv[1])
verdict = mod.image_exists(host, repo, tag)
if verdict is False:
sys.exit(1)
if verdict is None:
print(f" Could not reach the registry to check ghcr.io/wekan/wekan:v{sys.argv[1]};"
" continuing.")
PYEOF
then
echo "Error: ghcr.io/wekan/wekan:v${VERSION} does not exist." >&2
echo " A chart for it would install and then fail at the image pull, and" >&2
echo " Artifact Hub would report it as a scan error against wekan/charts." >&2
echo " Publish the image first (the docker job), then run this again." >&2
exit 1
fi
# ── 1. Bump only the WeKan version numbers on the main branch ────────────────
( cd "$CHARTS_DIR" && git checkout main && git pull )
@@ -79,7 +140,18 @@ echo " Updated Chart.yaml (appVersion ${VERSION}, version ${CHART_VERSION}) and
# ── 2. Build, commit+push main, package, and switch to gh-pages ──────────────
( cd "$CHARTS_DIR" && ./release.sh "$VERSION" )
# ── 3. Add the new chart to the gh-pages index.yaml ──────────────────────────
# ── 3. Rebuild the gh-pages index.yaml from the packages ─────────────────────
#
# The package is checked BEFORE it is indexed, because the one thing this step
# cannot recover from is a tarball whose name and contents disagree. charts'
# release.sh names the file from its argument (`tar -cvzf wekan-$1.0.tgz wekan`)
# while the version INSIDE comes from the Chart.yaml this script sed-ed a moment
# ago - so if those two ever drift, the repository gains a file called one
# version that declares another. That is not hypothetical: wekan-1.2.7.tgz
# (containing 1.2.6) and wekan-6.96.tgz (containing 6.9.6) are both in the
# repository's history, each colliding with the correctly named file of that
# version, and a Helm index keys on the version INSIDE, so neither could ever be
# installed under the name it was given.
TGZ="wekan-${CHART_VERSION}.tgz"
(
cd "$CHARTS_DIR"
@@ -87,81 +159,44 @@ TGZ="wekan-${CHART_VERSION}.tgz"
echo "Error: expected packaged chart not found: $CHARTS_DIR/$TGZ" >&2
exit 1
fi
# A Helm index digest is the bare sha256 hash. `sha256sum` prints
# "<hash> <filename>", so keep only the first field — otherwise the filename
# leaks into the digest value (digest: <hash> wekan-<ver>.tgz).
DIGEST="$(sha256sum "$TGZ" | awk '{print $1}')"
CHART_VERSION="$CHART_VERSION" DIGEST="$DIGEST" python3 - <<'PYEOF'
import os, re, sys
from datetime import datetime, timezone
chart_version = os.environ["CHART_VERSION"]
digest = os.environ["DIGEST"].strip()
url = f"https://wekan.github.io/charts/wekan-{chart_version}.tgz"
created = datetime.now(timezone.utc).astimezone().isoformat()
path = "index.yaml"
with open(path, encoding="utf-8") as f:
lines = f.readlines()
# Clean up legacy entries whose digest still carries the "<hash> <filename>"
# form (older releases stored the raw `sha256sum` output): keep only the hash.
digest_re = re.compile(r'^(\s*digest:\s+)([0-9a-fA-F]+)\b.*$')
lines = [digest_re.sub(r'\1\2', ln) if digest_re.match(ln) else ln for ln in lines]
# Locate the " wekan:" entries header; the newest entry is the first list item.
hdr = next((i for i, ln in enumerate(lines) if ln.rstrip("\n") == " wekan:"), None)
if hdr is None:
sys.exit("Could not find ' wekan:' in index.yaml")
start = hdr + 1
if start >= len(lines) or not lines[start].startswith(" - "):
sys.exit("Unexpected index.yaml format: no chart entry after ' wekan:'")
# The entry runs until the next list item (" - ") or a dedent to a top-level key.
end = len(lines)
for j in range(start + 1, len(lines)):
s = lines[j]
if s.startswith(" - ") or (s.strip() and not s.startswith(" ")):
end = j
break
block = lines[start:end]
def set_field(block, key, value):
pat = re.compile(r'^( ' + re.escape(key) + r': ).*$')
out, done = [], False
for ln in block:
if not done and pat.match(ln):
out.append(f" {key}: {value}\n")
done = True
else:
out.append(ln)
return out
block = set_field(block, "appVersion", f'"{chart_version}"')
block = set_field(block, "created", f'"{created}"')
block = set_field(block, "digest", digest)
block = set_field(block, "version", chart_version)
# Update the chart download URL (distinct from the github.com source URL).
out, done = [], False
for ln in block:
if not done and re.match(r'^ - https://wekan\.github\.io/charts/wekan-.*\.tgz\s*$', ln):
out.append(f" - {url}\n")
done = True
else:
out.append(ln)
block = out
# Insert as the new newest (top) entry, right after the " wekan:" header.
with open(path, "w", encoding="utf-8") as f:
f.writelines(lines[:start] + block + lines[start:])
print(f" Prepended chart {chart_version} to index.yaml (created {created}).")
inside="$(TGZ="$TGZ" python3 - <<'PYEOF'
import os, sys, tarfile, yaml
with tarfile.open(os.environ["TGZ"]) as tar:
members = [m for m in tar.getnames()
if m.count("/") == 1 and m.endswith("/Chart.yaml")]
if not members:
sys.exit("no <chart>/Chart.yaml in the package")
with tar.extractfile(members[0]) as fh:
print(yaml.safe_load(fh.read()).get("version", ""))
PYEOF
)"
if [ "$inside" != "$CHART_VERSION" ]; then
echo "Error: $TGZ declares chart version '${inside}', not '${CHART_VERSION}'." >&2
echo " The file name and its Chart.yaml disagree, so this package would be a" >&2
echo " second copy of ${inside} under a misleading name - which is how" >&2
echo " wekan-1.2.7.tgz and wekan-6.96.tgz came to exist. Not indexing it." >&2
exit 1
fi
)
# The index is DERIVED from the packages, never edited in place.
#
# It used to be edited: the newest entry was copied, a few fields substituted,
# and the result PREPENDED. Every one of the index's four defects came from that.
# Re-running a release prepended a second entry for the same version instead of
# replacing it (9.36.0 ended up with four, each with a different digest and the
# same url, so a helm client could pick a digest matching no file). appVersion
# was set to the CHART version rather than the WeKan version, on every entry. The
# fields nobody substituted never changed, so every entry claimed the mongodb
# subchart was 0.7.2 while the packages had moved to 0.7.6. And a release whose
# charts job did not run left a hole nothing ever went back for - 146 packages
# had no entry at all.
#
# Deriving it from the packages makes all four impossible rather than fixed:
# one entry per package, its digest computed from the file, its fields read out
# of the archive, and any package that was missed picked up on the next run.
"$REPO_DIR/releases/reindex-charts.py" --write --charts-dir "$CHARTS_DIR"
# ── 4. Commit and push the gh-pages branch ───────────────────────────────────
( cd "$CHARTS_DIR" && ./release2.sh "$VERSION" )
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1080,
appVersion = 1083,
# Increment this for every release.
appMarketingVersion = (defaultText = "10.80~2026-08-10"),
appMarketingVersion = (defaultText = "10.83~2026-08-11"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+3
View File
@@ -222,6 +222,9 @@ import '/server/methods/lockoutSettings';
import '/server/methods/migrateTextDatabase';
import '/server/methods/repairBoardData';
import '/server/methods/repairBrokenCards';
// Removes the "Templates" container boards made for accounts that never used
// them (pre-v10.00 signups). Dry run by default; see the method for why.
import '/server/methods/cleanupTemplateContainers';
import '/server/methods/systemStatus';
import '/server/methods/positionHistory';
import '/server/methods/sandstormMigration';
+133
View File
@@ -0,0 +1,133 @@
// Who is making this plain HTTP request?
//
// `Meteor.userId()` CANNOT ANSWER THAT. It reads the current DDP invocation's
// environment, which exists inside a method or a publication and nowhere else. In
// a WebApp/connect handler there is no such environment, so it does not return
// "nobody" - it throws:
//
// Meteor.userId can only be invoked in method calls or publications.
//
// Two file routes called it anyway, and because both wrap their body in a
// try/catch that turns any error into a 500, the failure looked like a broken
// file rather than broken authentication:
//
// server/routes/avatarServer.js /cdn/storage/avatars/:fileName
// server/routes/legacyAttachments.js
//
// That is why profile pictures came back as broken images after upgrading from an
// old MongoDB snap: the stored `profile.avatarUrl` of a 6.x install is
// `/cfs/files/avatars/<id>`, the legacy route redirects it to
// `/cdn/storage/avatars/<id>`, and that route could never serve anybody. The
// avatar files themselves migrated fine - the request for them was answered 500
// before a byte was read.
//
// An HTTP request carries its identity in the request: a bearer token, an
// X-Auth-Token header, an ?authToken= parameter, or the login cookie. This is
// the same resolution server/routes/universalFileServer.js has always done -
// lifted here so the routes that were guessing can share it rather than grow a
// third copy.
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
export function parseCookies(req) {
const header = req && req.headers && req.headers.cookie;
const out = {};
if (!header) return out;
for (const part of header.split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
try {
out[decodeURIComponent(part.slice(0, idx).trim())] =
decodeURIComponent(part.slice(idx + 1).trim());
} catch (_) {
// A cookie we cannot decode is not a reason to fail the request.
}
}
return out;
}
export function parseQuery(req) {
const out = {};
const q = ((req && req.url) || '').split('?')[1] || '';
if (!q) return out;
for (const pair of q.split('&')) {
if (!pair) continue;
const [rawK, rawV] = pair.split('=');
try {
out[decodeURIComponent((rawK || '').trim())] =
decodeURIComponent((rawV || '').trim());
} catch (_) {
// ditto
}
}
return out;
}
/** The login token this request carries, or null. Priority order matches
* universalFileServer's, so a request that works for an attachment works for an
* avatar. */
export function extractLoginToken(req) {
const authz = req && req.headers &&
(req.headers.authorization || req.headers.Authorization);
if (authz && typeof authz === 'string') {
const m = authz.match(/^Bearer\s+(.+)$/i);
if (m && m[1]) return m[1].trim();
}
const xAuth = req && req.headers &&
(req.headers['x-auth-token'] || req.headers['X-Auth-Token']);
if (xAuth && typeof xAuth === 'string') return xAuth.trim();
const q = parseQuery(req);
if (q.authToken && typeof q.authToken === 'string') return q.authToken.trim();
const cookies = parseCookies(req);
if (cookies.meteor_login_token) return cookies.meteor_login_token.trim();
if (cookies.wekan_login_token) return cookies.wekan_login_token.trim();
return null;
}
/** On Sandstorm the platform has already authorized the request and injects the
* user id as a header; there is no Meteor login token in that model at all. */
export function isSandstormRequest(req) {
return !!(
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm &&
req && req.headers && req.headers['x-sandstorm-user-id']
);
}
/** The user id behind this request, or null. Never throws: a caller deciding
* whether to serve a file wants an answer, not an exception that its own
* try/catch will turn into a 500. */
export async function getUserIdFromRequest(req) {
try {
if (isSandstormRequest(req)) {
return req.headers['x-sandstorm-user-id'];
}
const raw = extractLoginToken(req);
if (!raw || typeof raw !== 'string' || raw.length < 10) return null;
const hashed = Accounts._hashLoginToken(raw);
const user = await Meteor.users.findOneAsync(
{ 'services.resume.loginTokens.hashedToken': hashed },
{ fields: { _id: 1 } },
);
return user ? user._id : null;
} catch (error) {
if (process.env.DEBUG === 'true') {
console.warn('requestUser: could not resolve the request user:', error);
}
return null;
}
}
export default {
parseCookies,
parseQuery,
extractLoginToken,
isSandstormRequest,
getUserIdFromRequest,
};
+67
View File
@@ -0,0 +1,67 @@
// selectorGuard — refuse a client-supplied MongoDB selector that carries an
// EXECUTION operator, and record who sent it.
//
// GHSA-phm4-4v26-j2vq (WhereBleed). A publication or method that takes a query
// selector from the client and validates only its TYPE —
//
// check(query, Match.OneOf(Object, null));
//
// — has not validated it at all. A MongoDB selector is executable data: `$where`
// makes the database run the caller's JavaScript once per document scanned, so
// `{ $where: 'while(true){}' }` pins a database worker for as long as it likes,
// repeatably, and `{ $where: 'sleep(2000) || true' }` is a document-inclusion
// oracle whose truth value the caller controls. Reported against the Admin
// Panel's people, org, team and translation publications and their companion
// count/page methods — eight handlers, all taking the same shape of selector.
//
// The defence is NOT new here. classifySelector (models/lib/injectionDetect.js)
// and hasWhere (models/lib/mongoSelectorSafety.js) were written for exactly this
// and are wired into the card-window publication; the eight handlers simply never
// called them. This module is that publication's own helper, lifted out
// unchanged so there is ONE copy for every caller — the bug was eight places not
// doing what one place did, and a second copy of the check would be the same
// mistake waiting to happen again.
//
// Callers use the refusal cardsWindow.js already uses in production:
//
// const safe = selectorIsInjection(query, 'people') ? MATCH_NOTHING : query;
//
// MATCH_NOTHING is `{ _id: { $in: [] } }`: a selector that is valid, cheap and
// matches no document, so a refused request returns an empty result instead of
// throwing. Nothing an ordinary Admin Panel search, filter or page sends is
// affected — none of it contains an execution operator.
//
// See docs/Security/Remediation/WeKan.md §12.6 for the canary, and
// tests/selectorGuard.test.cjs for what counts as an injection.
import { tripCanary } from '/server/lib/canary';
const { hasWhere } = require('/models/lib/mongoSelectorSafety');
const { classifySelector, injectionDetail } = require('/models/lib/injectionDetect');
// The refusal: a well-formed selector that cannot match anything. Frozen so a
// caller cannot mutate the shared object into one that matches something.
export const MATCH_NOTHING = Object.freeze({ _id: Object.freeze({ $in: Object.freeze([]) }) });
// True when `selector` is an attempt to make the database run something rather
// than a query. `where` names the call site and only reaches the canary record,
// so an operator can see WHICH surface was probed.
export function selectorIsInjection(selector, where) {
const verdict = classifySelector(selector);
if (!verdict.injection && !hasWhere(selector)) return false;
tripCanary('injection.nosql-selector', {
detail: injectionDetail(
verdict.injection ? verdict : { kind: 'execution', operators: ['$where'] },
where,
),
});
return true;
}
// The whole pattern in one call, since every one of the nine call sites wants
// exactly this: hand back the selector, or a selector that matches nothing.
export function safeSelector(selector, where) {
return selectorIsInjection(selector, where) ? MATCH_NOTHING : selector;
}
export default { MATCH_NOTHING, selectorIsInjection, safeSelector };
+126
View File
@@ -0,0 +1,126 @@
import { Meteor } from 'meteor/meteor';
import { check, Match } from 'meteor/check';
import { ReactiveCache } from '/imports/reactiveCache';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
import {
templateContainersSelector,
planTemplateContainerCleanup,
} from '/models/lib/unusedTemplateContainers';
// Remove the "Templates" container boards that were created for accounts which
// never used them.
//
// Before v10.00 every new account got one at signup whether or not the person
// ever saved a template (#2339, #5850 made it lazy). Nothing ever removed the
// ones already made, and on an instance that provisions accounts automatically
// they come to dominate the boards collection: one reported instance has 14490
// boards of which 13404 are template containers, for 9264 accounts of which 478
// have ever logged in. Every query that touches boards carries that, and under
// polling reactivity that is every poll.
//
// TWO THINGS THIS IS CAREFUL ABOUT, because it deletes boards.
//
// It only ever removes an EMPTY container: nothing saved into it, nothing put in
// it directly, one member, not renamed, not starred, not archived by hand. If
// somebody used it, it is theirs and it stays. The rule lives in
// models/lib/unusedTemplateContainers.js, on its own, so it can be read and
// tested without this file.
//
// And it does NOTHING unless asked twice. The default is a dry run that reports
// what would go; `apply: true` is what deletes. An admin can therefore look at
// the list first, which for thirteen thousand boards is the difference between a
// cleanup and an accident.
const DEFAULT_TITLES = ['Templates', 'templates', 'Template Container'];
async function collectContainers(limit) {
const boards = await Boards.find(templateContainersSelector(), {
fields: { _id: 1, title: 1, type: 1, members: 1, archived: 1, starred: 1 },
...(limit ? { limit } : {}),
}).fetchAsync();
const entries = [];
for (const board of boards) {
// Counted rather than fetched: the question is only "is there anything in
// it", and these collections are the large ones.
const [templateCount, listCount, swimlaneCount, cardCount] = await Promise.all([
Boards.find({
type: { $in: ['template-board', 'template-list', 'template-card',
'template-swimlane'] },
'members.userId': { $exists: true },
_id: { $ne: board._id },
parentId: board._id,
}).countAsync(),
Lists.find({ boardId: board._id }).countAsync(),
Swimlanes.find({ boardId: board._id }).countAsync(),
Cards.find({ boardId: board._id }).countAsync(),
]);
entries.push({
board,
counts: { templateCount, listCount, swimlaneCount, cardCount },
options: { defaultTitles: DEFAULT_TITLES },
});
}
return entries;
}
Meteor.methods({
// options: { apply: false, limit: 0 }
async cleanupUnusedTemplateContainers(options = {}) {
check(options, Match.Optional(Object));
const apply = options.apply === true;
const limit = Number.isInteger(options.limit) && options.limit > 0
? options.limit
: 0;
if (!this.userId || !(await ReactiveCache.getUser(this.userId))?.isAdmin) {
throw new Meteor.Error('not-authorized', 'You must be an admin.');
}
const entries = await collectContainers(limit);
const plan = planTemplateContainerCleanup(entries);
if (!apply) {
return {
applied: false,
scanned: entries.length,
wouldRemove: plan.remove.length,
kept: plan.keep.length,
// A sample rather than thirteen thousand rows, and the reasons the kept
// ones were kept - which is what tells an admin the rule is doing what
// they think it is.
removeSample: plan.remove.slice(0, 25),
keepSample: plan.keep.slice(0, 25),
};
}
let removed = 0;
const failed = [];
for (const entry of plan.remove) {
try {
await Boards.removeAsync(entry.boardId);
// The pointer on the user profile goes with it, or the next
// ensureTemplatesBoard finds a board that is not there.
await Meteor.users.updateAsync(
{ 'profile.templatesBoardId': entry.boardId },
{ $unset: { 'profile.templatesBoardId': '' } },
{ multi: true },
);
removed += 1;
} catch (error) {
failed.push({ boardId: entry.boardId, error: String(error && error.message) });
}
}
return {
applied: true,
scanned: entries.length,
removed,
kept: plan.keep.length,
failed,
};
},
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { ReactiveCache } from '/imports/reactiveCache';
import { safeSelector } from '/server/lib/selectorGuard';
import Org from '/models/org';
import { ensureIndex } from '/server/lib/mongoStartup';
import { Authentication } from '/server/authentication';
@@ -290,7 +291,7 @@ Meteor.methods({
throw new Meteor.Error('not-authorized');
}
const cursor = await ReactiveCache.getOrgs(
tenantAdmin.orgScopeSelector(user, query || {}), {}, true);
tenantAdmin.orgScopeSelector(user, safeSelector(query || {}, 'getOrgsCollectionCount')), {}, true);
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
},
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { ReactiveCache } from '/imports/reactiveCache';
import { safeSelector } from '/server/lib/selectorGuard';
import Team from '/models/team';
import { ensureIndex } from '/server/lib/mongoStartup';
import { Authentication } from '/server/authentication';
@@ -254,7 +255,7 @@ Meteor.methods({
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
throw new Meteor.Error('not-authorized');
}
const cursor = await ReactiveCache.getTeams(query || {}, {}, true);
const cursor = await ReactiveCache.getTeams(safeSelector(query || {}, 'getTeamsCollectionCount'), {}, true);
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
},
});
+2 -1
View File
@@ -1,5 +1,6 @@
import { Meteor } from 'meteor/meteor';
import Translation from '/models/translation';
import { safeSelector } from '/server/lib/selectorGuard';
import { ensureIndex } from '/server/lib/mongoStartup';
const getReactiveCache = () => require('/imports/reactiveCache').ReactiveCache;
@@ -58,7 +59,7 @@ Meteor.methods({
if (!(await getReactiveCache().getCurrentUser())?.isAdmin) {
throw new Meteor.Error('not-authorized');
}
const cursor = await getReactiveCache().getTranslations(query || {}, {}, true);
const cursor = await getReactiveCache().getTranslations(safeSelector(query || {}, 'getTranslationsCollectionCount'), {}, true);
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
},
});
+35 -10
View File
@@ -3,6 +3,7 @@ import { WebApp } from 'meteor/webapp';
import { Accounts } from 'meteor/accounts-base';
import { Email } from 'meteor/email';
import { check, Match } from 'meteor/check';
import { safeSelector } from '/server/lib/selectorGuard';
import { EJSON } from 'meteor/ejson';
import { Random } from 'meteor/random';
import { CollectionHooks } from 'meteor/matb33:collection-hooks';
@@ -77,7 +78,11 @@ 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';
import {
expiredNotificationActivityIds,
cappedNotifications,
notificationCapFromEnv,
} from '/models/lib/notificationCleanup';
import { chooseInviteEmailLanguage } from '/models/lib/inviteEmailLanguage';
import { paginateDomains } from '/models/lib/domainTablePage';
import { orgsToAutoAddForEmail } from '/models/lib/orgAutoAddByDomain';
@@ -1712,19 +1717,39 @@ const runNotificationCleanup = async function runNotificationCleanup() {
const users = await ReactiveCache.getUsers({
'profile.notifications': { $exists: true, $ne: [] },
});
// #6533: the cap is a BACKSTOP for the array the rule above cannot shrink.
// Only READ notifications are ever pruned, so a user who does not clear their
// tray grows `profile.notifications` without limit - and every new
// notification is an `$addToSet` that rewrites that whole array inside the user
// document. On FerretDB's SQLite backend, which has one writer, those rewrites
// queue and start failing with SQLITE_BUSY while FerretDB burns CPU scanning
// the array. See models/lib/notificationCleanup.js.
const maxPerUser = notificationCapFromEnv(process.env);
for (const user of users) {
const notifications = user.profile && user.profile.notifications;
const activityIds = expiredNotificationActivityIds(
user.profile && user.profile.notifications,
notifications,
removeAge,
now,
);
if (activityIds.length === 0) continue;
// What the array would be after the $pull, so the cap is applied to the
// result rather than to a length that is about to shrink anyway.
const remaining = activityIds.length
? (notifications || []).filter(n => !(n && activityIds.includes(n.activity)))
: notifications;
const kept = cappedNotifications(remaining, maxPerUser);
if (activityIds.length === 0 && kept === null) continue;
try {
await Users.updateAsync(user._id, {
$pull: {
'profile.notifications': { activity: { $in: activityIds } },
},
});
// ONE write per user either way. When the cap applies, `$set` replaces the
// array outright - a `$pull` for the overflow would need the entries it is
// removing listed, and the point is that there are too many of them.
await Users.updateAsync(user._id, kept !== null
? { $set: { 'profile.notifications': kept } }
: {
$pull: {
'profile.notifications': { activity: { $in: activityIds } },
},
});
} catch (error) {
console.error(
'Notification cleanup: failed to prune notifications for user',
@@ -2340,7 +2365,7 @@ Meteor.methods({
}
const cursor = await ReactiveCache.getUsers(
tenantAdmin.peopleScopeSelector(currentUser, query || {}), {}, true);
tenantAdmin.peopleScopeSelector(currentUser, safeSelector(query || {}, 'getUsersCollectionCount')), {}, true);
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
},
@@ -2379,7 +2404,7 @@ Meteor.methods({
// The same selector, the same sort and the same window as the publication -
// anything else would name a different page than the one that was sent.
const cursor = await ReactiveCache.getUsers(
tenantAdmin.peopleScopeSelector(currentUser, query || {}),
tenantAdmin.peopleScopeSelector(currentUser, safeSelector(query || {}, 'getPeoplePageIds')),
{
limit,
skip: skip || 0,
+9 -19
View File
@@ -2,27 +2,17 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { publishComposite } from 'meteor/reywood:publish-composite';
import Boards from '/models/boards';
import Cards from '/models/cards';
const { hasWhere } = require('/models/lib/mongoSelectorSafety');
const { classifySelector, injectionDetail } = require('/models/lib/injectionDetect');
import { tripCanary } from '/server/lib/canary';
// A client-supplied selector is run against the database, so an execution
// operator in one is not a query - it is an attempt to make the database run
// something. The publication already refuses it; this names WHO tried, from
// where, and what they sent (docs/Security/Remediation/WeKan.md §12.6).
// Returns the same boolean the old hasWhere() call did, so both refusal sites
// below are unchanged.
function selectorIsInjection(selector, where) {
const verdict = classifySelector(selector);
if (!verdict.injection && !hasWhere(selector)) return false;
tripCanary('injection.nosql-selector', {
detail: injectionDetail(
verdict.injection ? verdict : { kind: 'execution', operators: ['$where'] },
where,
),
});
return true;
}
// something. This publication refuses it and names WHO tried, from where, and
// what they sent (docs/Security/Remediation/WeKan.md §12.6).
//
// The check used to live here as a local helper. GHSA-phm4-4v26-j2vq was eight
// OTHER handlers taking the same shape of client-supplied selector and never
// calling it, so it moved to /server/lib/selectorGuard - unchanged - and every
// caller now shares the one copy. Leaving a second copy behind here would be the
// same mistake set up to happen again.
import { selectorIsInjection } from '/server/lib/selectorGuard';
const {
boardCardScope,
assignedOnlyCardScope,
+7 -1
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { safeSelector } from '/server/lib/selectorGuard';
// Multitenancy option D (D.7): the site admin sees every Organization, a per-tenant
// Global Admin sees the ones they administer. Same rule module as the people
@@ -13,8 +14,13 @@ Meteor.publish('org', async function(query, limit, skip = 0) {
let ret = [];
const user = await ReactiveCache.getCurrentUser();
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
// nothing else, and a selector is executable data - `$where` makes the database
// run the caller's JavaScript per document scanned. Refuse one that carries an
// execution operator, with the same "match nothing" the card window uses.
const safeQuery = safeSelector(query, 'org');
if (tenantAdmin.canOpenAdminPanel(user)) {
ret = await ReactiveCache.getOrgs(tenantAdmin.orgScopeSelector(user, query),
ret = await ReactiveCache.getOrgs(tenantAdmin.orgScopeSelector(user, safeQuery),
{
limit,
skip: skip || 0,
+7 -1
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { safeSelector } from '/server/lib/selectorGuard';
// Multitenancy option D (docs/Design/Multitenancy/Multitenancy.md, D.7): the site
// admin still sees every user, and a PER-TENANT Global Admin sees the members of
@@ -15,8 +16,13 @@ Meteor.publish('people', async function(query, limit, skip = 0) {
let ret = [];
const user = await ReactiveCache.getCurrentUser();
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
// nothing else, and a selector is executable data - `$where` makes the database
// run the caller's JavaScript per document scanned. Refuse one that carries an
// execution operator, with the same "match nothing" the card window uses.
const safeQuery = safeSelector(query, 'people');
if (tenantAdmin.canOpenAdminPanel(user)) {
ret = await ReactiveCache.getUsers(tenantAdmin.peopleScopeSelector(user, query), {
ret = await ReactiveCache.getUsers(tenantAdmin.peopleScopeSelector(user, safeQuery), {
limit,
skip: skip || 0,
sort: { createdAt: -1 },
+7 -1
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { safeSelector } from '/server/lib/selectorGuard';
Meteor.publish('team', async function(query, limit, skip = 0) {
check(query, Match.OneOf(Object, null));
@@ -8,8 +9,13 @@ Meteor.publish('team', async function(query, limit, skip = 0) {
const user = await ReactiveCache.getCurrentUser();
let ret = [];
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
// nothing else, and a selector is executable data - `$where` makes the database
// run the caller's JavaScript per document scanned. Refuse one that carries an
// execution operator, with the same "match nothing" the card window uses.
const safeQuery = safeSelector(query, 'team');
if (user && user.isAdmin) {
ret = await ReactiveCache.getTeams(query,
ret = await ReactiveCache.getTeams(safeQuery,
{
limit,
skip: skip || 0,
+7 -1
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { safeSelector } from '/server/lib/selectorGuard';
// ONE page of custom translation strings (docs/Design/Page/Table.md): the limit and
// the skip are applied server-side, so only the rows that are displayed ever reach
@@ -13,8 +14,13 @@ Meteor.publish('translation', async function(query, limit, skip = 0) {
let ret = [];
const user = await ReactiveCache.getCurrentUser();
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
// nothing else, and a selector is executable data - `$where` makes the database
// run the caller's JavaScript per document scanned. Refuse one that carries an
// execution operator, with the same "match nothing" the card window uses.
const safeQuery = safeSelector(query, 'translation');
if (user && user.isAdmin) {
ret = await ReactiveCache.getTranslations(query,
ret = await ReactiveCache.getTranslations(safeQuery,
{
limit,
skip: skip || 0,
+43 -5
View File
@@ -5,6 +5,7 @@
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { getUserIdFromRequest, parseQuery } from '/server/lib/requestUser';
import { ReactiveCache } from '/imports/reactiveCache';
import Avatars from '/models/avatars';
import { fileStoreStrategyFactory } from '/models/avatars.server';
@@ -44,6 +45,27 @@ async function serveLegacyAvatar(fileId, req, res) {
return true;
}
// May a caller who is NOT signed in see this avatar?
//
// Only on a public board, and only for somebody who is on it. The client appends
// ?boardId= to every avatar URL it renders for exactly this case - see the
// avatarUrl helper in client/components/users/userAvatar.js, whose own comment
// says "so public viewers can access avatars on public boards" - and this route
// ignored the parameter, so a public board showed the missing-picture icon to
// every visitor who was not logged in.
//
// The board has to be named AND public AND actually have the avatar's owner on
// it. Without that last part, naming any public board would unlock any avatar on
// the instance, which is not what a public board publishes.
async function avatarIsOnAPublicBoard(req, avatar) {
const boardId = parseQuery(req).boardId;
if (!boardId || !avatar || !avatar.userId) {
return false;
}
const board = await ReactiveCache.getBoard(boardId);
return !!(board && board.isPublic() && board.hasMember(avatar.userId));
}
// Handle avatar file downloads
WebApp.handlers.use('/cdn/storage/avatars/:fileName', async (req, res, next) => {
if (req.method !== 'GET') {
@@ -81,9 +103,20 @@ WebApp.handlers.use('/cdn/storage/avatars/:fileName', async (req, res, next) =>
}
// Check if user has permission to view this avatar
// For avatars, we allow viewing by any logged-in user
const userId = Meteor.userId();
if (!userId) {
// For avatars, we allow viewing by any logged-in user.
//
// Resolved FROM THE REQUEST. This was `Meteor.userId()`, which reads the
// current DDP invocation's environment - it exists in a method or a
// publication and not in a WebApp handler, where it THROWS
// ("Meteor.userId can only be invoked in method calls or publications").
// The catch at the bottom of this handler turned that into a 500, so no
// avatar served through this route ever reached anybody, and it looked like
// a broken image rather than broken authentication. That is what a 6.x
// upgrade shows as lost profile pictures: `/cfs/files/avatars/<id>` is
// redirected here, and here is where it died - the migrated file itself was
// fine.
const userId = await getUserIdFromRequest(req);
if (!userId && !(await avatarIsOnAPublicBoard(req, avatar))) {
res.writeHead(401);
res.end('Authentication required');
return;
@@ -153,8 +186,13 @@ WebApp.handlers.use('/cfs/files/avatars/:fileName', async (req, res, next) => {
return;
}
// Not a legacy avatar — redirect to the new avatar URL format.
const newUrl = `/cdn/storage/avatars/${fileName}`;
// Not a legacy avatar — redirect to the new avatar URL format, KEEPING the
// query string. A redirect that drops it drops ?boardId=, which is how a
// visitor who is not signed in is allowed to see an avatar on a public board
// at all - and this is the redirect every migrated 6.x avatar URL goes
// through, so losing it there would 401 exactly the installs this fixes.
const query = (req.url || '').split('?')[1];
const newUrl = `/cdn/storage/avatars/${fileName}${query ? `?${query}` : ''}`;
res.writeHead(301, { 'Location': newUrl });
res.end();
+4 -1
View File
@@ -1,5 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { getUserIdFromRequest } from '/server/lib/requestUser';
import { ReactiveCache } from '/imports/reactiveCache';
import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
const { sanitizeDownloadFileName } = require('/imports/lib/fileNameDisplay');
@@ -88,7 +89,9 @@ if (Meteor.isServer) {
}
// Check if user has permission to download
const userId = Meteor.userId();
// From the REQUEST, not Meteor.userId(): this is a WebApp handler, where
// that throws rather than returning null (see server/lib/requestUser.js).
const userId = await getUserIdFromRequest(req);
if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
res.writeHead(403);
res.end('Access denied');
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# ferretdb-migration-stale — exit 0 if the completed MongoDB -> FerretDB migration
# is OLDER than the MongoDB data it was made from, i.e. MongoDB has been written
# to since. Exit 1 when it is current, when there is no completed migration, or
# when it cannot be told.
#
# WHY THIS EXISTS (wekan/wekan#6583: "Missing Data after Upgrade from v6").
#
# The migration copies MongoDB into FerretDB's SQLite and writes
# .migration-to-ferretdb-done. That copy is a SNAPSHOT, taken once. Nothing keeps
# it in step afterwards - so if MongoDB goes on being used after a migration
# completed, the SQLite beside it silently falls behind, by as long as that goes
# on.
#
# Which is exactly what a `snap revert` produces. Revert to a revision that runs
# mongod and WeKan carries on writing to MongoDB, for days or weeks, while the
# finished FerretDB copy sits frozen at the date it was made. Refresh forward
# again and mongodb-control saw a marker plus a non-empty SQLite, called that a
# completed migration and switched the snap to it - serving the frozen snapshot.
# Every board, card and comment written since the migration was still on disk in
# MongoDB and simply not being shown, which reads exactly like the reported
# "changes made in recent days were lost ... the state of the data from days ago".
#
# The database directory is $SNAP_COMMON, which is shared across revisions and NOT
# rolled back by `snap revert` - so the data was never in danger. What was wrong
# was WHICH of the two copies got served, and nothing compared their ages.
#
# HOW IT DECIDES. mongod rewrites its WiredTiger files whenever it commits, so the
# newest mtime under the data directory is when MongoDB was last written to. If
# that is later than when the migration finished, the migration is stale.
#
# The margin exists because the migration itself touches MongoDB on the way out:
# it starts a temporary mongod to read from and stops it again, and that shutdown
# writes to the data directory a moment after the marker appears. A few minutes
# of slack tells that apart from the case this is for, which is days or weeks.
#
# Usage: ferretdb-migration-stale [data-dir] [sqlite-dir]
# Env: FERRETDB_STALE_MARGIN_SECONDS default 600
DBPATH="${1:-${SNAP_COMMON:-/var/snap/${SNAP_INSTANCE_NAME:-${SNAP_NAME:-wekan}}/common}}"
SQLITE_DIR="${2:-$DBPATH/files/db}"
MARKER="$DBPATH/.migration-to-ferretdb-done"
MARGIN="${FERRETDB_STALE_MARGIN_SECONDS:-600}"
# No completed migration means nothing to be stale about. Both halves are needed:
# the marker is also written when the migration finishes with no data at all.
[ -f "$MARKER" ] || exit 1
have_sqlite=1
for f in "$SQLITE_DIR"/*.sqlite; do
if [ -f "$f" ] && [ -s "$f" ]; then have_sqlite=0; break; fi
done
[ "$have_sqlite" -eq 0 ] || exit 1
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo ""; }
marker_at="$(mtime "$MARKER")"
case "$marker_at" in ''|*[!0-9]*) exit 1 ;; esac
# The MongoDB data files, and only those: a log or a pid file in the same
# directory is touched by starting the snap and says nothing about whether the
# DATABASE was written to.
newest=0
newest_file=""
for f in "$DBPATH"/WiredTiger "$DBPATH"/WiredTiger.wt "$DBPATH"/WiredTiger.turtle \
"$DBPATH"/_mdb_catalog.wt "$DBPATH"/sizeStorer.wt \
"$DBPATH"/collection-*.wt "$DBPATH"/index-*.wt \
"$DBPATH"/journal/WiredTigerLog.* ; do
[ -f "$f" ] || continue
m="$(mtime "$f")"
case "$m" in ''|*[!0-9]*) continue ;; esac
if [ "$m" -gt "$newest" ]; then newest="$m"; newest_file="$f"; fi
done
# No MongoDB data at all: a FerretDB-only install, nothing to be behind.
[ "$newest" -gt 0 ] || exit 1
if [ "$newest" -gt "$((marker_at + MARGIN))" ]; then
behind=$(( (newest - marker_at) / 86400 ))
echo "The FerretDB migration finished $(date -d "@$marker_at" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "at $marker_at')")," >&2
echo "but MongoDB was written to as recently as $(date -d "@$newest" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$newest") ($(basename "$newest_file"))" >&2
echo "- about ${behind} day(s) later. The migrated copy is BEHIND the MongoDB data." >&2
exit 0
fi
exit 1
+5
View File
@@ -355,6 +355,11 @@ mongod_says_data_too_old() {
stop_data_too_old() {
local can; can="$(data_too_old_can_read)"
printf '%s\n' "$can" > "$SNAP_COMMON/.mongodb-data-too-old"
# #6471: record WHICH revision concluded this. The conclusion is about the
# readers this snap has, and a later one may have more - the 4.2 reader was
# added for exactly these databases. A newer revision therefore ignores this
# marker and tries again (snap-src/bin/stale-marker).
bash "$SNAP/bin/stale-marker" --stamp "$SNAP_COMMON/.mongodb-data-too-old"
log "The database files were made by an older MongoDB${can:+ (MongoDB $can or earlier can still read them)}."
log "This snap can read a MongoDB 6/7 database (mongod 7), a MongoDB 4.0/4.2 one (mongod 4.2) and a MongoDB 3.2 one (the migratemongo tools); these files are none of those, and no retry can change that."
log "NOTHING has been changed: your data, attachments and avatars are untouched."
+65 -3
View File
@@ -202,6 +202,18 @@ trap cleanup EXIT
# be restarted by snapd, re-trigger the migration, and loop - so do not start it at
# all. wekan-control serves the explanatory page; the data is untouched.
MONGODB_DATA_TOO_OLD="${SNAP_COMMON}/.mongodb-data-too-old"
# #6471: a marker written by an OLDER revision is not an answer about this one.
# It records that no reader in THAT snap could open the data - and v10.81 added a
# third reader (mongod 4.2) for exactly these databases. An instance that had
# already failed on v10.79/v10.80 carried the marker, so the version with the fix
# refused to start mongod, never ran the migration, and printed none of the new
# messages: upgrading changed nothing, which is what the reporter saw. Same
# revision, same refusal; new revision, new chance.
if [ -f "$MONGODB_DATA_TOO_OLD" ] && bash "$SNAP/bin/stale-marker" "$MONGODB_DATA_TOO_OLD"; then
echo "The 'database too old' marker was written by a different snap revision; this one may have readers it did not. Trying again."
rm -f "$MONGODB_DATA_TOO_OLD" "${MONGODB_DATA_TOO_OLD}.revision"
rm -f "${SNAP_COMMON}/.mongod-start-failures"
fi
if [ -f "$MONGODB_DATA_TOO_OLD" ]; then
_can="$(cat "$MONGODB_DATA_TOO_OLD" 2>/dev/null)"
echo "MongoDB will NOT be started: the database files were made by an older MongoDB${_can:+ (MongoDB $_can or earlier can still read them)},"
@@ -220,6 +232,20 @@ MONGOD_FAIL_COUNT_FILE="${SNAP_COMMON}/.mongod-start-failures"
# NOT switched to.) Returns 1 when there is no completed migration.
switch_to_completed_ferretdb() {
local svc="${SNAP_INSTANCE_NAME:-${SNAP_NAME:-wekan}}"
# #6583: "completed" is not the same as "up to date". The migration is a
# SNAPSHOT and nothing keeps it in step, so if MongoDB went on being written
# to afterwards - which is what a `snap revert` to a mongod revision produces,
# for as long as the revert lasts - the SQLite beside it is behind by that
# much. Switching to it then serves data from the day the migration ran and
# hides everything since, which is not a lost database (it is all still in
# $SNAP_COMMON, which `snap revert` does not roll back) but is indistinguishable
# from one to the person looking at it.
if bash "$SNAP/bin/ferretdb-migration-stale" "$SNAP_COMMON" 2>&1; then
echo "NOT switching to FerretDB: the migrated copy is OLDER than the MongoDB data."
echo "MongoDB keeps serving, so the newest data is the data you see. To move to"
echo "FerretDB, migrate again so the copy is current: snap run wekan.migrate"
return 1
fi
if [ -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
bash "$SNAP/bin/ferretdb-has-data" "$SNAP_COMMON/files/db" 2>/dev/null; then
echo "A completed FerretDB migration exists; switching to database=ferretdb."
@@ -246,18 +272,54 @@ handle_mongod_start_failure() {
switch_to_completed_ferretdb || true
# #6471: the count belongs to a REVISION, not to the installation. Three
# failures is loop protection - it stops migration<->mongod restarting each
# other forever - but it was never reset, so an instance that had failed more
# than three times on an older snap would not even attempt the migration on a
# newer one that could have completed it. A different revision starts at zero.
local fails=0
[ -f "$MONGOD_FAIL_COUNT_FILE" ] && fails="$(cat "$MONGOD_FAIL_COUNT_FILE" 2>/dev/null || echo 0)"
if [ -f "$MONGOD_FAIL_COUNT_FILE" ] && \
! bash "$SNAP/bin/stale-marker" "$MONGOD_FAIL_COUNT_FILE"; then
fails="$(cat "$MONGOD_FAIL_COUNT_FILE" 2>/dev/null || echo 0)"
fi
case "$fails" in (*[!0-9]*|'') fails=0;; esac
fails=$((fails + 1))
echo "$fails" > "$MONGOD_FAIL_COUNT_FILE"
bash "$SNAP/bin/stale-marker" --stamp "$MONGOD_FAIL_COUNT_FILE"
# MongoDB data that this mongod cannot serve: the only way forward is the
# MongoDB → FerretDB migration. Re-enable and run it now (it RESUMES from its
# checkpoint if one exists), unless it keeps failing too (counter guards
# against a migration↔mongod restart loop).
if [ "$fails" -le 3 ] && [ ! -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
# ...or a migration that COMPLETED but has since fallen behind the MongoDB data
# (#6583). mongod cannot start, so keeping MongoDB is not on offer here and the
# only two outcomes are a FRESH copy or a stale one. Migrate again: the
# migration reads the data with its own temporary mongod and the 4.2/3.2
# readers, none of which is the mongod that just failed, so it can still reach
# everything written since. Serving the stale copy instead would show data from
# the day it ran and say nothing about it.
local stale_migration=1
if [ -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
bash "$SNAP/bin/ferretdb-migration-stale" "$SNAP_COMMON" 2>/dev/null; then
stale_migration=0
fi
if [ "$fails" -le 3 ] && \
{ [ ! -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] || [ "$stale_migration" -eq 0 ]; } && \
{ [ -f "$SNAP_COMMON/WiredTiger" ] || ls "$SNAP_COMMON"/collection-*.wt >/dev/null 2>&1; }; then
if [ "$stale_migration" -eq 0 ]; then
# A stale migration has to be RESTARTED, not resumed. migration-control
# exits immediately while the marker is there ("already handled"), and
# the checkpoint plus the old SQLite describe a finished copy of
# last month's data - resuming onto that would keep every row the
# revert period added out of it. Clear the three things that say
# "already done" so the migration reads MongoDB again from the start.
# The MongoDB data itself is never touched, here or by the migration.
echo "The completed FerretDB migration is older than the MongoDB data; migrating again from scratch so nothing written since is left out."
rm -f "$SNAP_COMMON/.migration-to-ferretdb-done"
rm -f "$SNAP_COMMON/migration-progress.json"
rm -f "$SNAP_COMMON/.attachments-files-v2-done"
rm -rf "${SNAP_COMMON:?}/files/db/"* 2>/dev/null || true
fi
echo "mongod cannot serve this data; running the MongoDB -> FerretDB migration instead (attempt ${fails}/3)."
snapctl set migrate=on 2>/dev/null || true
rm -f "${SNAP_COMMON}/mongodb.pid"
@@ -353,7 +415,7 @@ until "$DB_EVAL" ping "$_RS_URL" >/dev/null 2>&1; do
done
# mongod is answering: clear the start-failure counter (see
# handle_mongod_start_failure) so past failures don't count against future ones.
[ $_wait -lt 90 ] && rm -f "$MONGOD_FAIL_COUNT_FILE"
[ $_wait -lt 90 ] && rm -f "$MONGOD_FAIL_COUNT_FILE" "${MONGOD_FAIL_COUNT_FILE}.revision"
# Check replica set status and current member host (prints OK:<host> or ERROR:<msg>)
_rs_check=$("$DB_EVAL" rs-conf-host "$_RS_URL" 2>/dev/null)
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
# stale-marker — is a "give up" marker one that a DIFFERENT snap revision wrote?
#
# Exit 0 when the marker exists and was written by a revision other than the one
# running now (so it is stale and should be ignored), 1 otherwise.
#
# WHY (wekan/wekan#6471). Two markers in $SNAP_COMMON stop the snap doing work:
#
# .mongodb-data-too-old written when no reader in the snap could open the
# MongoDB data. mongodb-control then refuses to start
# mongod at all and wekan-control serves an
# explanatory page instead of WeKan.
# .mongod-start-failures a counter; after three failures mongodb-control
# stops re-running the MongoDB -> FerretDB migration,
# so a migration<->mongod restart loop cannot form.
#
# Both are right, and both were PERMANENT. That is the bug: each records a
# conclusion about what THIS SNAP can do, and a later revision can do more. The
# 4.2 reader was added in v10.81 for exactly the databases the marker is written
# for - but an instance that had already failed on v10.79 or v10.80 carried a
# marker saying "no reader can open this", and the counter had long passed three,
# so v10.81 never started mongod, never ran the migration, and printed none of
# the new messages. The reporter upgraded to the version with the fix and saw no
# change at all, which is exactly what a permanent latch looks like from outside:
#
# Database selection: setting='mongodb' ferretdb_has_data=false mongodb_has_data=true
# Waiting for MongoDB replica set primary...
# MongoDB not ready yet, retrying in 5 seconds...
#
# A NEW REVISION IS A NEW CHANCE. Every marker records the revision that wrote
# it; when the running revision differs, the marker is stale and the snap tries
# again with whatever it can do now. Within one revision nothing changes: the
# loop protection and the "do not start mongod on data it cannot read" refusal
# both still hold, because the revision matches.
#
# The revision is kept in a COMPANION file (<marker>.revision) rather than inside
# the marker, because .mongodb-data-too-old's contents are read and displayed by
# wekan-control and wekan-maintenance-page.mjs, and this must not change what
# they show.
#
# Usage:
# stale-marker <marker-path> # exit 0 if stale (written elsewhere)
# stale-marker --stamp <marker-path> # record the current revision for it
#
# With no SNAP_REVISION - running outside a snap - nothing is ever called stale:
# not knowing the revision is not evidence that it changed.
MARKER="${2:-$1}"
[ -n "$MARKER" ] || exit 1
REV_FILE="${MARKER}.revision"
NOW="${SNAP_REVISION:-}"
if [ "$1" = "--stamp" ]; then
[ -n "$NOW" ] || exit 0
printf '%s\n' "$NOW" > "$REV_FILE" 2>/dev/null || true
exit 0
fi
[ -f "$MARKER" ] || exit 1
[ -n "$NOW" ] || exit 1
# No companion file: the marker predates this mechanism, so it was written by an
# older revision by definition - which is the case this exists for.
[ -f "$REV_FILE" ] || exit 0
WROTE="$(cat "$REV_FILE" 2>/dev/null)"
[ "$WROTE" = "$NOW" ] && exit 1
exit 0
+37
View File
@@ -30,6 +30,15 @@ CPU_EXEC="$SNAP/bin/cpu-exec"
# reason only in `snap logs`; serve a page that says what happened, that nothing was
# changed, and what the two ways forward are. Cleared by removing the marker (or by
# reverting the snap, which is one of the two).
# #6471: a marker from a DIFFERENT revision is not an answer about this one, so
# it must not keep the explanatory page up either. mongodb-control removes it and
# retries; this service can start first, so it makes the same judgement rather
# than serving "your data is too old for this snap" on a snap that has not tried.
if [ -f "$SNAP_COMMON/.mongodb-data-too-old" ] && \
bash "$SNAP/bin/stale-marker" "$SNAP_COMMON/.mongodb-data-too-old"; then
echo "The 'database too old' marker was written by a different snap revision; ignoring it so this one can try."
rm -f "$SNAP_COMMON/.mongodb-data-too-old" "$SNAP_COMMON/.mongodb-data-too-old.revision"
fi
if [ -f "$SNAP_COMMON/.mongodb-data-too-old" ]; then
echo "DATABASE TOO OLD FOR THIS SNAP (.mongodb-data-too-old present): serving the explanatory page instead of WeKan."
export PORT="${PORT:-80}"
@@ -76,6 +85,34 @@ if [ "ferretdb" = "$DATABASE" ] && [ "$ferretdb_has_data" != "true" ] && [ "$mon
snapctl set database=mongodb 2>/dev/null || true
fi
# SAFETY, the second half (#6583): never serve a FerretDB copy that is OLDER than the
# MongoDB data sitting beside it. The guard above catches an EMPTY FerretDB; this
# catches a FULL but out-of-date one, which looks worse - WeKan comes up, boards and
# cards are all there, and only the last weeks of work are missing, so it reads as
# data loss rather than as a misconfiguration.
#
# It gets that way because the migration is a snapshot and nothing keeps it in step.
# `snap revert` to a revision that runs mongod, carry on using WeKan for a month, and
# the finished SQLite is a month behind; refresh forward and it would be served.
# Nothing is lost either way - $SNAP_COMMON is shared across revisions and is not
# rolled back by a revert, so the MongoDB data is still there - so the right move is
# simply to serve the newer of the two and say why.
if [ "ferretdb" = "$DATABASE" ] && [ "$mongodb_has_data" = "true" ] && \
bash "$SNAP/bin/ferretdb-migration-stale" "$SNAP_COMMON" 2>/dev/null; then
echo "========================================================================"
echo "WeKan: the FerretDB database in $FERRETDB_SQLITE_DIR was migrated from MongoDB,"
echo "but the MongoDB data has been written to SINCE that migration - so the migrated"
echo "copy is missing everything added after it, and serving it would look like those"
echo "changes were lost. They are not: they are in the MongoDB data in $SNAP_COMMON,"
echo "which 'snap revert' does not roll back."
echo ""
echo "Staying on database=mongodb, which has the newest data. To move to FerretDB,"
echo "migrate again so the copy is current: snap run ${WEKAN_SNAP_SVC:-wekan}.migrate"
echo "========================================================================"
export DATABASE="mongodb"
snapctl set database=mongodb 2>/dev/null || true
fi
# Bring up the chosen database service and stop the other, so exactly one runs on the
# shared port and WeKan does not wait forever for a stopped service. snapctl start --enable
# = snap enable + snap start; targets the snap INSTANCE name so parallel snaps hit their own.
+65 -9
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '10.80'
version: '10.83'
base: core24
confinement: strict
grade: stable
@@ -380,6 +380,33 @@ parts:
override-build: |
set -eu
V=4.2.25
# The directory is created FIRST, before anything can decide to skip.
# `stage:` and `prime:` below name `mongo42` explicitly, and a filter
# that names a path the part did not produce is not ignored - snapcraft
# stops the whole build on it:
#
# Staging mongo42
# Failed to copy '/build/.../parts/mongo42/install/mongo42':
# no such file or directory.
# Build failed
#
# That is what failed EVERY Launchpad snap in v10.80 - armhf, s390x,
# ppc64el and riscv64, three attempts each - because this part is
# amd64/arm64 only and every other architecture took the `exit 0`
# below, leaving nothing to stage. The part was meant to be optional
# ("OPTIONAL BY DESIGN", above) and on those arches it silently was
# not: it took the entire snap down with it, on the four
# architectures that have no native runner and can only be built
# here.
#
# An empty directory stages fine and primes to a few bytes, and
# migration-control looks for the BINARY inside it, which is still
# not there - so "no MongoDB 4.2 to migrate from" stays exactly as
# true as it was, without being fatal.
dest="$SNAPCRAFT_PART_INSTALL/mongo42"
mkdir -p "$dest/bin" "$dest/lib"
case "${CRAFT_ARCH_BUILD_FOR}" in
amd64) MARCH=x86_64 ; DEBARCH=amd64 ;;
arm64) MARCH=aarch64 ; DEBARCH=arm64 ;;
@@ -389,8 +416,6 @@ parts:
;;
esac
dest="$SNAPCRAFT_PART_INSTALL/mongo42"
mkdir -p "$dest/bin" "$dest/lib"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
@@ -446,7 +471,13 @@ parts:
done
if [ "$got_ssl" != yes ]; then
echo "mongo42: could not obtain OpenSSL 1.1 for ${DEBARCH}; mongod 4.2 cannot run without it, so it is not staged."
rm -rf "$dest"
# The CONTENTS go, the DIRECTORY stays - `stage: mongo42` below
# names it, and snapcraft ends the whole build on a filter whose
# path is missing (see the comment at the top of override-build).
# An empty directory stages to nothing and migration-control
# guards every use on `-x $M42/bin/mongod`, so "no 4.2 reader" is
# still exactly what this means.
rm -rf "${dest:?}"/* 2>/dev/null || true
exit 0
fi
@@ -455,7 +486,8 @@ parts:
# already down, so the build asks it here.
if ! LD_LIBRARY_PATH="$dest/lib" "$dest/bin/mongod" --version >/dev/null 2>&1; then
echo "mongo42: the staged mongod 4.2 does not run in this environment; removing it rather than shipping something that cannot start."
rm -rf "$dest"
# Contents only, directory kept - same reason as above.
rm -rf "${dest:?}"/* 2>/dev/null || true
exit 0
fi
echo "mongo42: staged $("$dest/bin/mongod" --version 2>/dev/null | head -1 || echo "mongod $V") for ${CRAFT_ARCH_BUILD_FOR}."
@@ -496,18 +528,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-10.80-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-10.83-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v10.80/wekan-10.80-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v10.83/wekan-10.83-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v10.80/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v10.83/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
@@ -526,7 +558,31 @@ parts:
fi
cd bundle/programs/server
rm -rf node_modules
npm install
# Retried, because on Launchpad this is the step that loses the race
# with the network. Every request goes through the build farm's proxy,
# and one tarball out of a couple of thousand can simply be cut:
#
# npm ERR! code ECONNRESET
# npm ERR! network request to https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz
# failed, reason: socket hang up
# 'override-build' in part 'wekan' failed with code 1.
#
# which is what ended the riscv64 snap in v10.80 after it had already
# built for half an hour. Nothing is wrong with the build when that
# happens and asking again fixes it; npm's own --fetch-retries only
# covers the request, not a socket dropped mid-stream. Three tries,
# then it is a real failure and the log says so.
for attempt in 1 2 3; do
if npm install; then
break
fi
if [ "$attempt" = 3 ]; then
echo "npm install failed three times in a row - this is not the network." >&2
exit 1
fi
echo "npm install failed (attempt ${attempt}/3); retrying in 15s."
sleep 15
done
cd ../../../..
# Copy to Snap. Use the bundle's OWN arch-correct Node.js: the
# wekan-<arch>.zip already ships the right node for this platform
+219
View File
@@ -0,0 +1,219 @@
'use strict';
// #6488, reported again against 10.77 after being closed three times:
//
// "at smartphone, at All Boards page, it is not possible to scroll down to
// see remaining of boards. check that scrolling down works at all left menu
// options right pages, and at mobile and desktop mode. also, some board icons
// are too high, they are maybe 4x normal board icon height or more."
// "board icons are too close to each other. there should be small amount of
// space between board icons."
//
// Four separate things, and each earlier attempt fixed one shape of one of them:
//
// 1. THE SCROLL. Every viewport guess below `body` had been removed from
// boardsList.css - and the last one was in layouts.css, where nobody looked:
// `body.mobile-mode #content { height: calc(100dvh - 48px) }`. 48px is a
// guess at the header, the header is not 48px and is not any one number
// (Utils publishes --wekan-header-height from a ResizeObserver for exactly
// this reason), so on a phone whose bar wraps, #content ended below the
// screen and everything under it inherited that. Covered by
// tests/mobileViewportHeight.test.cjs.
//
// 2. THE TABLE VIEW. The right column has two branches - the board icons and
// `+tablePage` - and only the icons were given a scroller, so the other view
// of every section could not be scrolled at all.
//
// 3. THE 4x-TALL TILES. A grid defaults to `align-content: stretch`, so a list
// shorter than its column has the leftover height divided among its rows and
// each tile grown into it; a board tile paints its colour over the whole
// cell. This was fixed on `.board-list.mobile-view`, the class the template
// sets from Utils.isMiniScreen() - but the phone MEDIA QUERY makes a grid
// too, and a narrow window that is not a mini screen took that path and
// never got the fix.
//
// 4. THE SPACING. An 8px grid gap PLUS a `margin-bottom: 0.5rem` per tile: 16px
// between rows and 8px between columns, which reads as crowded sideways.
// One value, both directions.
//
// Run: node tests/allBoardsPhoneScrollAndTiles.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const css = read('client/components/boards/boardsList.css');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// A rule's declarations, comments stripped - a rule that EXPLAINS what it no
// longer says must not read as still saying it.
function rule(selector, from = css) {
const at = from.indexOf(`\n${selector} {`) !== -1
? from.indexOf(`\n${selector} {`)
: from.indexOf(`${selector} {`);
assert.notStrictEqual(at, -1, `no rule for ${selector}`);
const open = from.indexOf('{', at);
return from.slice(open + 1, from.indexOf('}', open))
.replace(/\/\*[\s\S]*?\*\//g, '');
}
function decl(body, prop) {
const m = body.match(new RegExp(`(?:^|;|\\n)\\s*${prop}\\s*:\\s*([^;\\n]+)`));
return m ? m[1].trim().replace(/\s*!important$/, '') : null;
}
// The phone media query that turns the board list into a grid.
const phoneGrid = (() => {
const at = css.indexOf('grid-template-columns: repeat(2, minmax(0, 1fr));');
assert.notStrictEqual(at, -1, 'the 2-per-row phone grid must exist');
const open = css.lastIndexOf('{', at);
return css.slice(open + 1, css.indexOf('}', at)).replace(/\/\*[\s\S]*?\*\//g, '');
})();
// ── 3. the 4x-tall tiles ────────────────────────────────────────────────────
test('the phone board grid does not stretch its rows or its tiles', () => {
assert.strictEqual(decl(phoneGrid, 'align-content'), 'start',
'without align-content: start a grid divides the column\'s leftover height ' +
'among its rows, so one row of boards becomes one band several times a ' +
'tile\'s height - the "4x normal board icon height" in the report');
assert.strictEqual(decl(phoneGrid, 'align-items'), 'start',
'and without align-items: start each tile is stretched to its row');
});
test('the mini-screen grid does not stretch either, so both paths agree', () => {
const mobileView = rule('.board-list.mobile-view');
assert.strictEqual(decl(mobileView, 'display'), 'grid');
// The stretch fix for this path lives in its own late rule (source order
// decides at equal specificity), so it is looked for across the file.
const at = css.indexOf('.board-list.mobile-view {\n align-content: start;');
assert.notStrictEqual(at, -1,
'.board-list.mobile-view must also state align-content/align-items: start');
});
test('a tile is its own height, not the height of the cell it sits in', () => {
const li = (() => {
const at = phoneGrid.length && css.indexOf(' .board-list li {');
assert.notStrictEqual(at, -1, 'the phone rule for .board-list li must exist');
return css.slice(css.indexOf('{', at) + 1, css.indexOf('}', at))
.replace(/\/\*[\s\S]*?\*\//g, '');
})();
assert.strictEqual(decl(li, 'align-self'), 'start',
'a grid item stretches to its row unless it says otherwise');
assert.strictEqual(decl(li, 'height'), 'auto', 'and takes its own content height');
});
// ── 4. the spacing ──────────────────────────────────────────────────────────
test('one spacing value separates the tiles, in both directions', () => {
const gap = decl(phoneGrid, 'gap');
assert.ok(gap, 'the phone grid must set a gap');
const px = parseInt(gap, 10);
assert.ok(px >= 8, `the gap must be visible between tiles, got ${gap}`);
assert.ok(/^\d+px$/.test(gap),
`one length, applied to both rows and columns - got ${gap}`);
});
test('no per-tile margin adds a second, one-directional gap', () => {
for (const [selector, body] of [
['.board-list li (phone)', css.slice(css.indexOf(' .board-list li {'),
css.indexOf('}', css.indexOf(' .board-list li {'))).replace(/\/\*[\s\S]*?\*\//g, '')],
['.board-list.mobile-view li', rule('.board-list.mobile-view li')],
]) {
const mb = decl(body, 'margin-bottom');
assert.strictEqual(mb, '0',
`${selector}: the grid gap spaces the tiles. A margin here spaced them a ` +
`second time and only downwards, so the rows were twice as far apart as ` +
`the columns - which is what "too close to each other" describes sideways`);
}
});
// ── 2. every left-menu option's right page scrolls ──────────────────────────
test('the right column is a flex column with a scrolling part in BOTH views', () => {
const jade = read('client/components/boards/boardsList.jade');
// The two branches the template really has, so this test fails if a third
// appears without a scroller.
assert.ok(/if isAllBoardsView 'table'\s*\n\s*\+tablePage/.test(jade),
'the Table branch of the right column must still be +tablePage');
assert.ok(/ul\.board-list/.test(jade), 'the icons branch must still be .board-list');
// The PHONE rule (two-space indent, inside the media query) - not the
// page-level `.boards-layout > .boards-right-grid` padding rule near the top
// of the file, which a loose substring search finds first.
const rgAt = css.indexOf('\n .boards-right-grid {');
assert.notStrictEqual(rgAt, -1, 'the phone rule for .boards-right-grid must exist');
const rightGrid = css.slice(css.indexOf('{', rgAt) + 1, css.indexOf('}', rgAt))
.replace(/\/\*[\s\S]*?\*\//g, '');
assert.strictEqual(decl(rightGrid, 'display'), 'flex');
assert.strictEqual(decl(rightGrid, 'flex-direction'), 'column');
const table = rule('.boards-right-grid > .table-page');
assert.strictEqual(decl(table, 'flex'), '1 1 auto',
'the table view must take what the column has left');
assert.strictEqual(decl(table, 'min-height'), '0',
'and be allowed to be shorter than the table, or it can never scroll');
assert.ok(/auto/.test(decl(table, 'overflow-y')),
'a table taller than the phone must scroll inside the column - #content is ' +
'overflow: hidden, so anything past the fold is otherwise clipped');
assert.ok(/auto|scroll/.test(decl(table, 'overflow-x')),
'a table is wider than a phone too');
});
test('the pane heading does not get squeezed instead of the scroller scrolling', () => {
// Again the PHONE rule: there is a page-level
// `.boards-right-grid > .admin-pane-title` earlier in the file that sets the
// heading's margins, and a loose search finds that one.
const tAt = css.indexOf('\n .boards-right-grid > .admin-pane-title {');
assert.notStrictEqual(tAt, -1, 'the phone rule for the pane title must exist');
const title = css.slice(css.indexOf('{', tAt) + 1, css.indexOf('}', tAt))
.replace(/\/\*[\s\S]*?\*\//g, '');
assert.strictEqual(decl(title, 'flex'), '0 0 auto',
'a flex item shrinks by default; the title must keep its height and let the ' +
'list below it be the flexible one');
});
test('the left menu is its own scroller, so its options are reachable too', () => {
// "check that scrolling down works at all left menu options" - the menu
// itself has to scroll when there are more workspaces than fit.
const menu = css.slice(css.indexOf(' .boards-left-menu {\n overflow-y: auto;'));
assert.ok(menu.length, 'the phone rule making the left menu a scroller must exist');
const body = menu.slice(menu.indexOf('{') + 1, menu.indexOf('}')).replace(/\/\*[\s\S]*?\*\//g, '');
assert.strictEqual(decl(body, 'overflow-y'), 'auto');
assert.strictEqual(decl(body, 'min-height'), '0');
assert.strictEqual(decl(body, 'height'), '100%');
});
// ── 1. the chain the scroll hangs from, end to end ──────────────────────────
test('nothing between the body and the board list computes a height from the viewport', () => {
const layouts = read('client/components/main/layouts.css');
const strip = s => s.replace(/\/\*[\s\S]*?\*\//g, '');
// #content: the flex item, no arithmetic (the fix in layouts.css).
const contentAt = layouts.indexOf('body.mobile-mode #content {');
const content = strip(layouts.slice(contentAt, layouts.indexOf('}', contentAt)));
assert.ok(!/height:\s*calc\(/.test(content),
'body.mobile-mode #content must be sized by flex: 1, not by 100dvh minus a ' +
'guessed header height - that is what put the bottom of All Boards off screen');
// ...and each step below it is a definite height that can also shrink.
for (const [selector, expect] of [
['#content .wrapper', { height: '100%', 'min-height': '0' }],
['#content .wrapper > .boards-layout', { flex: '1 1 auto', 'min-height': '0' }],
]) {
const at = css.indexOf(` ${selector} {`);
assert.notStrictEqual(at, -1, `the phone rule for ${selector} must exist`);
const body = strip(css.slice(css.indexOf('{', at) + 1, css.indexOf('}', at)));
for (const [prop, value] of Object.entries(expect)) {
assert.strictEqual(decl(body, prop), value,
`${selector} must state ${prop}: ${value} - a step that cannot shrink ` +
`below its content stops every scroller under it from scrolling`);
}
}
});
console.log(`\n${passed} passed`);
+198
View File
@@ -0,0 +1,198 @@
'use strict';
// Guard for releases/backfill-charts.sh - the script that answers "which WeKan
// releases should the Helm index list?" and repairs what release-charts.sh left.
// Run: node tests/backfillCharts.test.cjs
//
// Two facts about the live wekan/charts index this is written against:
//
// * It is INCOMPLETE. An entry is written once, by release-charts.sh, during
// the release it belongs to - so a release whose charts job did not run
// leaves a hole nothing ever fills. v10.80 is one of those (its charts job
// was skipped when a cancelled build-mac-x64 cancelled the run) and it is far
// from alone: 216 of 690 releases have an entry.
//
// * It has DUPLICATES, and they are the dangerous kind. 9.36.0 appears four
// times and 10.30.0 twice, every copy with a DIFFERENT digest and the SAME
// url - because release-charts.sh PREPENDS an entry each time it runs, so
// re-running a release duplicated its version instead of replacing it. At
// most one of those digests can belong to the wekan-9.36.0.tgz that is
// actually served; a helm client that picks another fails the integrity check
// on a file that is perfectly good.
//
// The dedup rule is checked here by running the script's own embedded python
// against a synthesised index, because the rule is the part with a decision in
// it: the entry whose digest MATCHES the package on disk wins - not the newest,
// which is only the fallback for when no package is there to compare against.
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..');
const scriptPath = path.join(repoRoot, 'releases/backfill-charts.sh');
const script = fs.readFileSync(scriptPath, 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
test('the script exists and is executable', () => {
assert.ok(fs.existsSync(scriptPath), 'releases/backfill-charts.sh is missing');
assert.ok(fs.statSync(scriptPath).mode & 0o111, 'backfill-charts.sh must be executable');
});
test('it writes nothing without --apply, and pushes nothing without --push', () => {
assert.ok(/if \[ "\$APPLY" != true \]; then/.test(script),
'the plan must return before anything is written');
assert.ok(/if \[ "\$PUSH" != true \]; then/.test(script),
'even --apply must not push a rewritten public index without --push');
const applyGate = script.indexOf('if [ "$APPLY" != true ]; then');
const helmPackage = script.indexOf('helm package');
const gitPush = script.indexOf('git push origin gh-pages');
assert.ok(applyGate !== -1 && helmPackage > applyGate,
'nothing may be packaged before the --apply gate');
assert.ok(gitPush > script.indexOf('if [ "$PUSH" != true ]; then'),
'the push must come after the --push gate');
});
test('a release with no container image is omitted, not listed', () => {
assert.ok(/omit\s*=\s*\[v for v, ok in zip\(candidates, have_image\) if ok is False\]/.test(script),
'a release whose image does not exist must land in "omit"');
assert.ok(/return None/.test(script) && /if ok is None/.test(script),
'a network failure must be "unknown", not "no image" - otherwise a hiccup ' +
'omits a release that has one');
});
test('an already-published chart is never repackaged', () => {
assert.ok(/if f"\{ver\}\.0" in published:\s*\n\s*keep\.append\(ver\)/.test(script),
'a version already in the index goes to "keep"; repackaging it would change ' +
'a digest that helm clients have already seen');
});
test('the published-chart scan reads entry versions, not dependency versions', () => {
assert.ok(/\^ \{4\}version:/.test(script),
"the index's own entries are at four spaces; the mongodb subchart's " +
"version: is at six, and a loose \\s* counts 0.7.2 as a published WeKan chart");
});
// The dedup rule, run for real.
function runDedup(dir) {
const body = script.split("python3 - <<'PYEOF'\nimport hashlib")[1].split('\nPYEOF')[0];
const py = path.join(dir, 'dedup.py');
fs.writeFileSync(py, 'import hashlib' + body);
return execFileSync('python3', [py], { cwd: dir, encoding: 'utf8' });
}
// A minimal index in the real shape: entry keys at four spaces, a dependency
// block at six, and the " wekan:" header the script looks for.
function makeIndex(entries) {
const head = 'apiVersion: v1\nentries:\n wekan:\n';
const body = entries.map(e =>
` - apiVersion: v2\n` +
` appVersion: "${e.version}"\n` +
` created: "${e.created}"\n` +
` dependencies:\n` +
` - name: mongodb\n` +
` version: 0.7.2\n` +
` digest: ${e.digest}\n` +
` name: wekan\n` +
` urls:\n` +
` - https://wekan.github.io/charts/wekan-${e.version}.tgz\n` +
` version: ${e.version}\n`).join('');
return head + body + 'generated: "2026-08-11T00:00:00.000000+00:00"\n';
}
function readVersions(file) {
return fs.readFileSync(file, 'utf8')
.split('\n')
.filter(l => /^ {4}version: /.test(l))
.map(l => l.trim().replace('version: ', ''));
}
test('a duplicated version is reduced to the entry whose digest matches the package', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-charts-'));
try {
const pkg = Buffer.from('pretend chart package');
const realDigest = crypto.createHash('sha256').update(pkg).digest('hex');
fs.writeFileSync(path.join(dir, 'wekan-9.36.0.tgz'), pkg);
// The matching digest is on the OLDEST copy on purpose: if "newest wins" were
// the rule rather than the fallback, this is the case that would expose it.
fs.writeFileSync(path.join(dir, 'index.yaml'), makeIndex([
{ version: '10.79.0', created: '2026-08-10T02:46:52+00:00', digest: 'a'.repeat(64) },
{ version: '9.36.0', created: '2026-06-11T03:41:02+00:00', digest: 'b'.repeat(64) },
{ version: '9.36.0', created: '2026-06-11T02:26:50+00:00', digest: 'c'.repeat(64) },
{ version: '9.36.0', created: '2026-06-10T21:20:06+00:00', digest: realDigest },
]));
const out = runDedup(dir);
const versions = readVersions(path.join(dir, 'index.yaml'));
assert.deepStrictEqual(versions, ['10.79.0', '9.36.0'],
'one entry per version must remain, and the untouched version must survive');
const kept = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
assert.ok(kept.includes(realDigest),
'the entry kept for 9.36.0 must be the one whose digest is the digest of ' +
'wekan-9.36.0.tgz - a helm client checks the package against it');
assert.ok(kept.includes('2026-06-10T21:20:06'),
'which here is the OLDEST copy: the digest decides, the timestamp does not');
assert.ok(/2 duplicate\(s\) removed/.test(out), `expected a report of 2 removals, got: ${out}`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('with no package to compare against, the newest duplicate is the fallback', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-charts-'));
try {
// No wekan-10.30.0.tgz in the directory, so nothing can be verified.
fs.writeFileSync(path.join(dir, 'index.yaml'), makeIndex([
{ version: '10.30.0', created: '2026-07-23T12:50:37+00:00', digest: 'd'.repeat(64) },
{ version: '10.30.0', created: '2026-07-23T11:58:18+00:00', digest: 'e'.repeat(64) },
]));
runDedup(dir);
const kept = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
assert.deepStrictEqual(readVersions(path.join(dir, 'index.yaml')), ['10.30.0']);
assert.ok(kept.includes('2026-07-23T12:50:37'), 'the newest is the fallback');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('an index with no duplicates is left exactly as it was', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-charts-'));
try {
const before = makeIndex([
{ version: '10.79.0', created: '2026-08-10T02:46:52+00:00', digest: 'a'.repeat(64) },
{ version: '10.78.0', created: '2026-08-09T02:46:52+00:00', digest: 'b'.repeat(64) },
]);
fs.writeFileSync(path.join(dir, 'index.yaml'), before);
const out = runDedup(dir);
assert.strictEqual(fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8'), before,
'a clean index must not be rewritten at all - no reformatting, no reordering');
assert.ok(/0 duplicate\(s\) removed/.test(out), out);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('the charts job reports the gap on every release', () => {
const workflow = fs.readFileSync(
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
assert.ok(/backfill-charts\.sh \| tee -a "\$GITHUB_STEP_SUMMARY"/.test(workflow),
'the charts job should print the plan into the run summary, so a hole in the ' +
'index is visible the release it appears in');
// Comments may name `--apply --push` as the command to run by hand; what must
// not exist is a line that actually RUNS it.
const invocations = workflow.split('\n')
.filter(l => l.includes('backfill-charts.sh'))
.filter(l => !/^\s*#/.test(l.trim()) && !l.trim().startsWith('#'));
const publishing = invocations.filter(l => /--push|--apply/.test(l));
assert.deepStrictEqual(publishing, [],
'the workflow must never package or push a rewritten index by itself:\n ' +
publishing.join('\n '));
});
console.log(`\n${passed} passed`);
+4 -1
View File
@@ -53,7 +53,10 @@ const WIRED = [
['export.path-outside-storage', 'models/exporter.js', 'PathBleed'],
['database.canary', 'server/lib/databaseProblems.js', '-'],
// Injection - the attacker sends a query instead of a value.
['injection.nosql-selector', 'server/publications/cardsWindow.js', '-'],
// GHSA-phm4-4v26-j2vq: this trip moved OUT of cardsWindow.js into the shared
// guard, because eight other handlers needed the same check and a second copy
// of it would be the same bug set up to happen again.
['injection.nosql-selector', 'server/lib/selectorGuard.js', '-'],
['injection.nosql-operator', 'models/lib/injectionDetect.js', '-'],
['injection.sql-statement', 'server/lib/databaseProblems.js', 'EscapeBleed'],
// Sanitization - something DANGEROUS had to be removed, not merely tidied.
+131
View File
@@ -0,0 +1,131 @@
'use strict';
// #6465, the two newest comments:
//
// csonkaoszimt: "is there a way to implement the whole closing an open card by
// clicking on it again thing?"
// Heart1010: "agree, closing by clicking again would be nice."
// "And I found this small glitch at the problems page (v10.81.0)"
//
// Run: node tests/cardClickToggle.test.cjs
//
// TWO THINGS, and the first is not a missing feature - it is an unreachable one.
//
// clickOnMiniCard already ended with:
//
// } else if (Session.equals('currentCard', card._id)) {
// Utils.goBoardId(Session.get('currentBoard')); // close it
//
// but the TITLE branch above it returns before that is ever reached, and the
// title covers most of a minicard. So the second click almost always landed in
// the title branch and re-opened the card that was already open, which is why
// this reads to users as a feature that was never built. The title branch now
// makes the same decision, and the phone's popup path does too.
//
// The second is the Problems page glitch in the screenshot: a checkbox that is
// both checked and focused drew as a blue DIAMOND. The checked rule turns the
// square into a tick by rotating the element 40deg, and a browser draws its focus
// ring around the element AS IT IS - rotated. The ring moves to the row instead,
// which is not rotated. Keyboard focus stays visible; it just is not a diamond.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const listBody = read('client/components/lists/listBody.js');
const settingCss = read('client/components/settings/settingBody.css');
const problemsJade = read('client/components/settings/problemsSummary.jade');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// The click handler, so the assertions are about it and not about the file.
const handler = (() => {
const at = listBody.indexOf('this.clickOnMiniCard = (evt) => {');
assert.notStrictEqual(at, -1, 'listBody.js must still define clickOnMiniCard');
return listBody.slice(at, listBody.indexOf('\n this.toggleMultiSelection', at));
})();
// ── clicking an open card again ─────────────────────────────────────────────
test('the title branch closes the card when it is already the open one', () => {
const titleAt = handler.indexOf('if (clickedTitle && !clickedLinkedReference)');
assert.notStrictEqual(titleAt, -1, 'the title branch must still exist');
const branch = handler.slice(titleAt, handler.indexOf('if (Utils.isMiniScreen())', titleAt));
assert.ok(/Session\.equals\('currentCard', card\._id\)/.test(branch),
'the title is most of a minicard, so this is where the second click lands - ' +
'it has to ask whether the card is already open');
assert.ok(/Utils\.goBoardId\(Session\.get\('currentBoard'\)\)/.test(branch),
'and close it the same way the existing branch below does');
const check = branch.indexOf("Session.equals('currentCard'");
const open = branch.indexOf('openCardWindow(card._id)');
assert.ok(check !== -1 && open !== -1 && check < open,
'the close decision must come BEFORE re-opening, or it never happens');
});
test('opening a DIFFERENT card from a title click still works', () => {
const titleAt = handler.indexOf('if (clickedTitle && !clickedLinkedReference)');
const branch = handler.slice(titleAt, handler.indexOf('if (Utils.isMiniScreen())', titleAt));
assert.ok(/Session\.set\('currentCard', card\._id\)/.test(branch) &&
/openCardWindow\(card\._id\)/.test(branch),
'a click on a card that is not open must still open it');
assert.ok(/Session\.delete\('popupCardId'\)/.test(branch),
'and still clear the phone popup ids, which is what that branch did before');
});
test('the original toggle further down is untouched', () => {
// Clicking a non-title part of an open card already closed it; this fix must
// not change that path.
assert.ok(/\} else if \(Session\.equals\('currentCard', card\._id\)\) \{[\s\S]{0,200}Utils\.goBoardId/.test(handler),
'the else-if that already toggled must stay, so both click targets agree');
});
test('on a phone, the second click closes the card popup', () => {
const at = handler.indexOf('if (Utils.isMiniScreen())');
const branch = handler.slice(at, handler.indexOf('} else if', at));
assert.ok(/Session\.equals\('popupCardId', card\._id\) && Popup\.isOpen\(\)/.test(branch),
'on a mini screen the card is a popup, so "already open" is a different ' +
'question - and it has to be asked before opening another one');
assert.ok(/Popup\.back\(\)/.test(branch), 'and closed as a popup, not by routing');
});
test('multi-selection still wins over everything', () => {
const multi = handler.indexOf('MultiSelection.isActive() || evt.shiftKey');
const title = handler.indexOf('if (clickedTitle && !clickedLinkedReference)');
assert.ok(multi !== -1 && multi < title,
'selecting cards must still take precedence, or a click during ' +
'multi-selection would open or close a card instead of ticking it');
});
// ── the Problems page checkbox ──────────────────────────────────────────────
test('the checked checkbox is still the rotated tick', () => {
const at = settingCss.indexOf('.setting-content input[type="checkbox"]:checked {');
assert.notStrictEqual(at, -1, 'the checked style must still exist');
const rule = settingCss.slice(at, settingCss.indexOf('}', at));
assert.ok(/transform: rotate\(40deg\)/.test(rule),
'the tick IS the rotation - this fix is about the focus ring, not the tick');
});
test('the focus ring is not drawn on the rotated element', () => {
assert.ok(/\.setting-content input\[type="checkbox"\]:focus[\s\S]{0,120}outline: none/.test(settingCss),
'a ring around a 40deg-rotated box renders as the blue diamond in the report');
});
test('...but focus is still visible, on the row that is not rotated', () => {
assert.ok(/label:has\(> input\[type="checkbox"\]:focus-visible\)[\s\S]{0,160}outline: 2px solid/.test(settingCss),
'removing focus indication outright would be worse than the glitch; it moves ' +
'to the containing label');
assert.ok(/:focus-visible/.test(settingCss),
'and :focus-visible, so a mouse click does not leave a ring behind');
});
test('the markup the ring relies on is really there', () => {
assert.ok(/label\.admin-problem-item\s*\n\s*input\.js-problem-check\(type="checkbox"/.test(problemsJade),
'the fix puts the outline on the label because the checkbox is inside one; ' +
'if that nesting changes, the ring has nowhere to go');
});
console.log(`\n${passed} passed`);
+135
View File
@@ -0,0 +1,135 @@
'use strict';
// wekan/wekan#6584 "It is not possible to move a card downwards":
//
// "With v6 i was able to move a Card downwards by drag & Drop. Upwards is no
// problem, the Line scrolls automaticly up, but this does not work downwards.
// The whole Page/Site scrolls down and not ne Line"
//
// Run: node tests/cardDragVerticalScroll.test.cjs
//
// The card sortable's `sort` handler auto-scrolls at the edges. Horizontally it
// picks the LANE under the pointer (#443). Vertically it did not pick anything:
// it always scrolled `.board-canvas`, which holds the swimlanes, rather than the
// `.list-body` under the pointer, which is `overflow-y: scroll` and holds the
// cards. Scrolling the canvas moves the whole board - "the whole Page/Site
// scrolls down and not ne Line".
//
// THE ASYMMETRY IS THE TELL, and it is what makes this reproducible rather than
// vague. Dragging UP, the canvas is usually already at scrollTop 0, so
// computeEdgeScroll returned null, this handler did nothing, and jQuery UI's own
// `scroll` option - which acts on the placeholder's scrollParent, i.e. the list
// body - scrolled the list, which is why up "is no problem". Dragging DOWN, the
// canvas nearly always has room left, so the handler fired first and scrolled the
// board instead.
//
// The list under the pointer now scrolls first, and the canvas only once the list
// cannot go further that way - so a drag down a long list scrolls the list, and a
// drag past the end of it moves on to the board.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const listJs = fs.readFileSync(
path.join(repoRoot, 'client/components/lists/list.js'), 'utf8');
const listCss = fs.readFileSync(
path.join(repoRoot, 'client/components/lists/list.css'), 'utf8');
const { computeEdgeScroll, findLaneUnderPointer } =
require(path.join(repoRoot, 'imports/lib/boardAutoScroll.js'));
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// The `sort` handler, which is where the auto-scroll lives.
const sortHandler = (() => {
const at = listJs.indexOf(' sort(event, ui) {');
assert.notStrictEqual(at, -1, 'list.js must still have the sortable sort handler');
return listJs.slice(at, listJs.indexOf("\n $cards.sortable('refreshPositions');", at));
})();
test('the list body is still the element that scrolls cards', () => {
const at = listCss.indexOf('.list-body {');
assert.notStrictEqual(at, -1, 'list.css must still style .list-body');
const rule = listCss.slice(at, listCss.indexOf('}', at));
assert.ok(/overflow-y:\s*scroll/.test(rule),
'if the cards ever stop scrolling inside .list-body, the drag auto-scroll ' +
'has to follow them to whatever does');
});
test('the vertical auto-scroll picks the list under the pointer', () => {
assert.ok(/document\.querySelectorAll\('\.list-body'\)/.test(sortHandler),
'the handler must look at the list bodies, not only at .board-canvas - ' +
'scrolling the canvas moves the whole board, which is the reported symptom');
const listAt = sortHandler.indexOf(".querySelectorAll('.list-body')");
const canvasAt = sortHandler.indexOf(".querySelector('.board-canvas')");
assert.ok(listAt !== -1 && canvasAt !== -1 && listAt < canvasAt,
'and it must try the list BEFORE the canvas');
});
test('the canvas is only scrolled when the list could not be', () => {
assert.ok(/scrolledList \? null : document\.querySelector\('\.board-canvas'\)/.test(sortHandler),
'a drag past the end of a list should still move on to the board, but a drag ' +
'down a list that can still scroll must not move the board underneath it');
});
test('the horizontal lane scroll is untouched', () => {
// #443 is the reason the lane hit-test exists; this fix must not disturb it.
assert.ok(/document\.querySelectorAll\('\.js-lists'\)/.test(sortHandler),
'the lane lookup must stay');
assert.ok(/scrollLeft = nextLeft/.test(sortHandler), 'and still set scrollLeft');
});
test('positions are still re-cached after any auto-scroll', () => {
// #6477: jQuery UI caches geometry at drag start; a manual scroll invalidates
// it and the drop lands in the wrong swimlane without this.
assert.ok(/if \(scrolled\) \{/.test(listJs) &&
/refreshPositions/.test(listJs),
'scrolling the list is a manual scroll like any other and must refresh too');
assert.ok(/scrolled = true;/.test(sortHandler.slice(sortHandler.indexOf('.list-body'))),
'the list branch must set `scrolled`, or the refresh is skipped for it');
});
// ── the decision logic itself, exercised with real numbers ──────────────────
test('a pointer near the bottom of a scrollable list scrolls it DOWN', () => {
// A list body 600px tall on screen, 2000px of cards, currently at the top.
// The pointer is 10px above its bottom edge: inside the 40px edge zone.
const next = computeEdgeScroll({
pointer: 690, lowEdge: 100, highEdge: 700,
scrollPos: 0, scrollSize: 2000, clientSize: 600,
});
assert.strictEqual(next, 15, 'it should step down by SCROLL_STEP');
});
test('...and stops at the end instead of handing the drag to the board', () => {
const next = computeEdgeScroll({
pointer: 690, lowEdge: 100, highEdge: 700,
scrollPos: 1400, scrollSize: 2000, clientSize: 600,
});
assert.strictEqual(next, null,
'at maxScroll it returns null - which is exactly what lets the canvas take over');
});
test('a list that does not overflow never claims the scroll', () => {
const next = computeEdgeScroll({
pointer: 690, lowEdge: 100, highEdge: 700,
scrollPos: 0, scrollSize: 600, clientSize: 600,
});
assert.strictEqual(next, null, 'a short list must let the board scroll instead');
});
test('the list under the pointer is the one chosen, not the first on the board', () => {
// Three lists side by side; the pointer is over the middle one.
const rects = [
{ left: 0, right: 200, top: 100, bottom: 700 },
{ left: 210, right: 410, top: 100, bottom: 700 },
{ left: 420, right: 620, top: 100, bottom: 700 },
];
assert.strictEqual(findLaneUnderPointer(rects, 300, 690), 1);
assert.strictEqual(findLaneUnderPointer(rects, 300, 900), -1,
'below every list, nothing is chosen and the canvas handles it');
});
console.log(`\n${passed} passed`);
+228
View File
@@ -0,0 +1,228 @@
'use strict';
// #1591 "Collapsable checklist on card" (open since 2018):
//
// "Long checklists can make a card pretty cluttered, so being able to collapse
// them and expand only when needed would keep the board much cleaner."
//
// Run: node tests/checklistCollapse.test.cjs
//
// WeKan already had something adjacent and it is NOT this: a checklist's own
// `hideAllChecklistItems` field, reachable through a toggle switch inside the
// checklist actions popup. That is a field ON THE CHECKLIST, so flipping it
// changes what EVERYONE on the board sees, and it is a data edit rather than a
// view preference - the wrong thing for "get this out of my way while I read the
// card", and buried where nobody folding a list would look.
//
// Collapsing is per-user, and WeKan already says so twice in its own models:
//
// models/lists.js: "collapsed state is per-user only, stored in user
// profile.collapsedLists"
// models/swimlanes.js: "collapsed state is per-user only ... Use
// user.setCollapsedSwimlane(boardId, swimlaneId, ...)"
//
// So cards follow that: profile.collapsedCardSections[cardId][sectionKey], with
// a caret on the title rather than a third entry in a menu. ONE map covers every
// foldable thing on a card - a whole feature group uses its own section name, an
// individual checklist uses 'checklist-<id>' - so the opened card and the
// minicard agree about what was folded. This suite pins the shape against the
// two siblings, because the value of following an existing pattern is entirely
// in actually following it.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const usersModel = read('models/users.js');
const jade = read('client/components/cards/checklists.jade');
const js = read('client/components/cards/checklists.js');
const css = read('client/components/cards/checklists.css');
const minicardJade = read('client/components/cards/minicard.jade');
const minicardJs = read('client/components/cards/minicard.js');
const detailsJs = read('client/components/cards/cardDetails.js');
const detailsCss = read('client/components/cards/cardDetails.css');
const en = JSON.parse(read('imports/i18n/data/en.i18n.json'));
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// ── the state, and where it lives ───────────────────────────────────────────
test('the collapsed state is per user, beside lists and swimlanes', () => {
assert.ok(/'profile\.collapsedCardSections': \{/.test(usersModel),
'it belongs in the user profile, like collapsedLists and collapsedSwimlanes');
for (const sibling of ['profile.collapsedLists', 'profile.collapsedSwimlanes']) {
assert.ok(usersModel.includes(`'${sibling}': {`),
`${sibling} must still be there - this is meant to sit alongside them`);
}
});
test('ONE map covers every foldable thing on a card', () => {
assert.ok(/getCollapsedCardSection\(cardId, sectionKey\)/.test(usersModel));
assert.ok(/setCollapsedCardSection\(cardId, sectionKey, collapsed\)/.test(usersModel));
assert.ok(/current\[cardId\]\[sectionKey\] = !!collapsed;/.test(usersModel),
'two checklists on the same card fold independently, so the section key ' +
'cannot be dropped');
assert.ok(/checklistSectionKey\(checklistId\) \{[\s\S]{0,120}checklist-\$\{checklistId\}/.test(usersModel),
'a checklist needs ONE key, because the opened card and the minicard have ' +
'to agree about which thing was folded');
});
test('"never set" is distinguishable from "expanded"', () => {
const at = usersModel.indexOf('getCollapsedCardSection(cardId, sectionKey)');
const body = usersModel.slice(at, usersModel.indexOf('\n },', at));
assert.ok(/typeof collapsedCardSections\[cardId\]\[sectionKey\] === 'boolean'/.test(body),
'a stored false and an absent entry must not be the same thing, or a ' +
'deliberate expand cannot be told from a default');
assert.ok(/return null;/.test(body), 'absent returns null, as the siblings do');
});
test('it does NOT touch the checklist document', () => {
// The distinction this whole feature turns on.
const setter = usersModel.slice(usersModel.indexOf('async setCollapsedCardSection'));
const body = setter.slice(0, setter.indexOf('\n },'));
assert.ok(/Users\.updateAsync/.test(body), 'it writes the USER');
assert.ok(!/Checklists\./.test(body),
'writing the checklist would change what everyone on the board sees, which ' +
'is what hideAllChecklistItems already does and is not what was asked for');
});
test('the existing shared hideAllChecklistItems is left exactly as it was', () => {
const checklists = read('models/checklists.js');
assert.ok(/toggleHideAllChecklistItems/.test(checklists),
'it has its own uses (it also drives showChecklist / isFinished) and this ' +
'feature must not have quietly replaced it');
assert.ok(/hideAllChecklistItems/.test(jade),
'and its effect on items is still rendered');
});
// ── the control ─────────────────────────────────────────────────────────────
test('the caret is on the title, not another entry in the actions menu', () => {
assert.ok(/a\.fa\.checklist-collapse\.js-collapse-checklist\(/.test(jade),
'the point is to fold at a glance while reading the card');
const titleAt = jade.indexOf('.checklist-title');
const caretAt = jade.indexOf('js-collapse-checklist');
const menuAt = jade.indexOf('js-open-checklist-details-menu');
assert.ok(titleAt < caretAt && caretAt < menuAt,
'inside the title row, before the actions menu');
});
test('the caret says which way it goes, and to a screen reader too', () => {
const block = jade.slice(jade.indexOf('js-collapse-checklist'), jade.indexOf('js-open-checklist-details-menu'));
assert.ok(/fa-caret-right/.test(block) && /fa-caret-down/.test(block),
'right when folded, down when open - or it is a button with no state');
assert.ok(/aria-expanded=/.test(block), 'and the state is exposed, not just drawn');
assert.ok(/role="button"/.test(block) && /tabindex="0"/.test(block),
'it is an <a> with no href, so it needs both to be reachable');
});
test('folding hides the items and keeps the progress bar', () => {
assert.ok(/unless checklistCollapsed\s*\n\s*\+checklistItems/.test(jade),
'the items are what gets folded away');
const progressAt = jade.indexOf('.checklist-progress-bar-container');
const unlessAt = jade.indexOf('unless checklistCollapsed');
assert.ok(progressAt !== -1 && progressAt < unlessAt,
'the progress bar stays: it is the summary of what was folded, so hiding it ' +
'too would leave a title that says nothing');
});
test('the click does not also open the rename form underneath it', () => {
const at = js.indexOf("'click .js-collapse-checklist'");
const body = js.slice(at, js.indexOf('\n },', at));
assert.ok(/event\.preventDefault\(\)/.test(body) && /event\.stopPropagation\(\)/.test(body),
'the caret sits inside the title, which opens the inline rename form');
assert.ok(/setCollapsedCardSection\(cardId, key, !collapsed\)/.test(body),
'and it toggles this user\'s state');
});
test('it works from the keyboard', () => {
assert.ok(/'keydown \.js-collapse-checklist'/.test(js),
'a link with no href is not reachable by keyboard without this');
const at = js.indexOf("'keydown .js-collapse-checklist'");
const body = js.slice(at, js.indexOf('\n },', at));
assert.ok(/event\.key !== 'Enter' && event\.key !== ' '/.test(body),
'Enter and Space are the keys a button answers');
});
test('a logged-out reader is not broken by it', () => {
const at = js.indexOf('checklistCollapsed() {');
const body = js.slice(at, js.indexOf('\n },', at));
assert.ok(/if \(!user\) return false;/.test(body),
'public boards have no current user; the checklist must render expanded ' +
'rather than throwing');
});
test('the caret is styled and has a hit area', () => {
assert.ok(/\.checklist-title \.checklist-collapse \{/.test(css));
assert.ok(/padding:/.test(css.slice(css.indexOf('.checklist-title .checklist-collapse {'))),
'a single glyph you have to aim at is worse than no glyph');
assert.ok(/:focus-visible/.test(css), 'and keyboard focus has to be visible on it');
});
test('both labels exist in English', () => {
assert.strictEqual(en['collapse-checklist'], 'Collapse checklist');
assert.strictEqual(en['expand-checklist'], 'Expand checklist');
const block = jade.slice(jade.indexOf('js-collapse-checklist'), jade.indexOf('js-open-checklist-details-menu'));
assert.ok(/_ 'expand-checklist'/.test(block) && /_ 'collapse-checklist'/.test(block),
'the title has to say what the click will DO, which is the opposite of the ' +
'current state');
});
// ── the minicard, and the other feature groups ──────────────────────────────
test('the minicard has the same caret, under the same key', () => {
assert.ok(/js-collapse-checklist/.test(minicardJade),
'a checklist shown on a minicard must fold there too');
assert.ok(/unless checklistCollapsed\s*\n\s*\.checklist-items/.test(minicardJade),
'and folding must hide its items');
const at = minicardJs.indexOf("'click .js-collapse-checklist'");
assert.notStrictEqual(at, -1, 'the minicard needs its own handler');
const body = minicardJs.slice(at, minicardJs.indexOf('\n },', at));
assert.ok(/user\.checklistSectionKey\(checklist\._id\)/.test(body),
'the SAME key as the opened card, or folding in one place would not fold in ' +
'the other');
assert.ok(/event\.preventDefault\(\)/.test(body) && /event\.stopPropagation\(\)/.test(body),
'a minicard is a link to the card; folding must not open it');
});
test('every feature group on the opened card folds, by its title', () => {
assert.ok(/'click \.card-details-item > \.card-details-item-title'/.test(detailsJs),
'one delegated handler rather than an edit to each of the sixteen sections');
assert.ok(/setCollapsedCardSection\(card\._id, key, !collapsed\)/.test(detailsJs),
'stored under the same per-user map as the checklists');
assert.ok(/card-details-item-\$\{|replace\('card-details-item-', ''\)/.test(detailsJs),
"the section's own class is its key; there is no separate id to invent");
});
test('a folded section hides what follows the title, whatever that is', () => {
assert.ok(/\.card-details-item\.is-collapsed > \*:not\(\.card-details-item-title\) \{\s*\n\s*display: none;/.test(detailsCss),
'the sections are NOT uniform inside - all sixteen open with a title, only ' +
'three wrap the rest in a content element - so hiding the siblings is what ' +
'works for all of them');
assert.ok(/\.card-details-item\.is-collapsed > \.card-details-item-title::after/.test(detailsCss),
'and the caret has to flip, or a folded section looks like an empty one');
});
test('a fold survives reopening the card', () => {
assert.ok(/collapsedCardSections/.test(detailsJs) && /is-collapsed/.test(detailsJs),
'the click toggles a class, but a fresh render only has the stored state - ' +
'without re-applying it, folding would last until the card was closed');
const at = detailsJs.indexOf('Template.cardDetails.onRendered');
const body = detailsJs.slice(at, at + 1800);
assert.ok(/classList\.toggle\('is-collapsed'/.test(body),
'applied on render, from the profile');
});
test('the caret CSS is direction-agnostic', () => {
// tests/rtl.test.js enforces this across component CSS; named here because a
// caret is exactly the kind of thing that gets a physical float.
assert.ok(!/float:\s*(left|right)/.test(css),
'a caret floated left sits on the wrong side of an RTL card');
assert.ok(/float:\s*inline-start/.test(css));
});
console.log(`\n${passed} passed`);
+80
View File
@@ -0,0 +1,80 @@
'use strict';
// The database-conformance run must not depend on a port nothing in it uses.
// Run: node tests/conformanceDebugPort.test.cjs
//
// From the 2026-08-11_16-55-04 run: EVERY backend failed before a single query
// was compared.
//
// db-conformance-sqlite.log:
// Failed to create debug handler ... listen tcp 127.0.0.1:8088:
// bind: address already in use
// db-conformance-summary.txt:
// ERROR sqlite FerretDB did not start on this backend
// ERROR postgresql FerretDB did not start on this backend
//
// FerretDB opens a debug handler for metrics and profiling at 127.0.0.1:8088 by
// default, and EXITS when that address is taken. An unrelated FerretDB happened
// to be running on the machine, so the whole conformance stage reported a
// database problem that was nothing of the sort.
//
// The script already takes this seriously for the two ports it knows about - it
// picks a free wire port, and says in its own comment that it is "chosen so this
// can run WHILE something else is running". The debug port was simply never
// passed, so it always took the default. Nothing in the run queries it, so it is
// not opened at all: `--debug-addr=-` is how FerretDB's main.go spells that
// (`if addr := cli.DebugAddr; addr != "" && addr != "-"`).
//
// Verified by hand against the built binary, with 8088 deliberately held: without
// the flag FerretDB does not come up, with it it does.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const script = fs.readFileSync(
path.join(repoRoot, 'releases/db-conformance.sh'), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// Every line that starts the FerretDB being tested.
const launches = script.split('\n').filter(l => /"\$FERRET_BIN" --handler=/.test(l));
test('every backend launch is found', () => {
assert.strictEqual(launches.length, 4,
'sqlite, postgresql, mysql and hana - if a backend is added it needs the ' +
`same treatment; found ${launches.length}`);
});
test('no launch opens the debug handler', () => {
// Checked on the whole command, which spans continuation lines.
const commands = script.split('"$FERRET_BIN" --handler=').slice(1)
.map(chunk => chunk.split(';;')[0]);
assert.strictEqual(commands.length, 4);
for (const command of commands) {
const handler = (command.match(/^(\w+)/) || [])[1];
assert.ok(/--debug-addr=-/.test(command),
`the ${handler} launch must pass --debug-addr=- : nothing in the run asks ` +
`the debug handler anything, and leaving it on the default 8088 makes the ` +
`whole stage fail when anything else on the machine is using it`);
}
});
test('the wire port is still chosen to be free, as it was', () => {
// The fix must not have replaced the care that was already there.
assert.ok(/FERRET_PORT="\$\(free_port /.test(script),
'the port FerretDB serves on is still moved off a busy one');
assert.ok(/WEKAN_CONFORMANCE_PORT/.test(script) && /WEKAN_CONFORMANCE_DB_PORT/.test(script),
'and both remain overridable');
});
test('the reason is written down where the ports are explained', () => {
assert.ok(/debug handler/.test(script) && /8088/.test(script),
'the next person choosing ports here needs to know there is a third one');
assert.ok(/address already in use/.test(script),
'quoting the failure is what makes it recognisable when it recurs');
});
console.log(`\n${passed} passed`);
+103
View File
@@ -0,0 +1,103 @@
'use strict';
// Email feedback (../log/wekan17/email-feedback/card-date):
//
// "If i write the expiration date with the keyboard it turns red, if i choose
// it with the date picker it is yellow. Can you please tell me the difference?"
//
// Run: node tests/datePickerYear.test.cjs
//
// It is a bug, and the colour was never the bug. `<input type="date">` reports
// its value as YYYY-MM-DD, but the browser lets the YEAR sub-field be typed as
// two digits and reports exactly what was typed: entering 31-12-26 gives
// "0026-12-31" - the year 26 AD. `new Date('0026-12-31T17:00:00')` is a perfectly
// valid Date, so nothing refused it, and the card was saved with a due date two
// thousand years in the past. Red is what an overdue date is supposed to look
// like; picking the same day from the calendar always fills four digits, gets
// 2026, and is yellow because it is merely due soon.
//
// The screenshot the reporter attached shows it directly: the yellow badges read
// "31-12-2026 17:00" and the red ones "31-12-26 17:00" - two different years, not
// two different colours of the same date.
//
// So the submit refuses a year outside a plausible window and says which digits
// are missing. It REFUSES rather than correcting: 0026 was probably meant as
// 2026, but silently rewriting a date somebody typed - one that other people's
// due-date reminders hang off - is worse than telling them.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const src = fs.readFileSync(path.join(repoRoot, 'client/lib/datepicker.js'), 'utf8');
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, 'imports/i18n/data/en.i18n.json'), 'utf8'));
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// The check as the submit handler does it.
const MIN = Number((src.match(/const MIN_PLAUSIBLE_YEAR = (\d+);/) || [])[1]);
const MAX = Number((src.match(/const MAX_PLAUSIBLE_YEAR = (\d+);/) || [])[1]);
const accepted = year => year >= MIN && year <= MAX;
test('the plausible-year window is defined', () => {
assert.ok(Number.isInteger(MIN) && Number.isInteger(MAX),
'datepicker.js must define MIN_PLAUSIBLE_YEAR and MAX_PLAUSIBLE_YEAR');
assert.ok(MIN < MAX);
});
test('the reported case is refused', () => {
// What the browser really hands over when 31-12-26 is typed.
const typed = new Date('0026-12-31T17:00:00');
assert.strictEqual(typed.getFullYear(), 26,
'this is the value the date input reports - a real Date, in the year 26');
assert.strictEqual(accepted(typed.getFullYear()), false,
'and it must not be stored as a due date');
});
test('the date the reporter meant is accepted', () => {
const picked = new Date('2026-12-31T17:00:00');
assert.strictEqual(picked.getFullYear(), 2026);
assert.strictEqual(accepted(picked.getFullYear()), true,
'the same day chosen from the calendar must go through unchanged');
});
test('the window is wide enough for the dates WeKan really holds', () => {
// Received dates can be genuinely old and deadlines genuinely far out; this is
// only meant to catch a year that cannot have been intended.
for (const year of [1970, 1999, 2000, 2026, 2100, 3000]) {
assert.strictEqual(accepted(year), true, `${year} is a date somebody may mean`);
}
for (const year of [1, 26, 99, 999, 10000]) {
assert.strictEqual(accepted(year), false, `${year} cannot have been intended`);
}
});
test('the refusal happens on submit, before the date is stored', () => {
const check = src.indexOf('MIN_PLAUSIBLE_YEAR ||');
const store = src.indexOf('storeDate.call(tpl, newCompleteDate)');
assert.ok(check !== -1 && store !== -1 && check < store,
'a year that cannot be right must never reach storeDate');
assert.ok(/tpl\.datePicker\.error\.set\('invalid-year'\)/.test(src),
'and the field must say so, the same way invalid-date and invalid-time do');
assert.ok(/evt\.target\.date\.focus\(\)/.test(src.slice(check, store)),
'with the cursor put back in the date field, which is where the fix is');
});
test('the message tells the user what to type', () => {
assert.ok(en['invalid-year'], 'en.i18n.json must carry the invalid-year string');
assert.ok(/four digits/i.test(en['invalid-year']),
'the whole confusion was a two-digit year, so the message has to name that - ' +
`got: ${en['invalid-year']}`);
// The neighbouring errors are still there; this is an addition, not a rename.
assert.ok(en['invalid-date'] && en['invalid-time']);
});
test('the date is not silently corrected to the year the user probably meant', () => {
assert.ok(!/\+ 2000|year < 100 \?|20\$\{/.test(src),
'rewriting 0026 to 2026 would be a guess about a date other people\'s ' +
'reminders hang off; it is refused and named instead');
});
console.log(`\n${passed} passed`);
+120
View File
@@ -0,0 +1,120 @@
'use strict';
// GHSA-6p5m-f9p2-wqm5 — cross-board IDOR in the single-card Excel export.
// Run: node tests/exportExcelCardContainment.test.cjs
//
// GET /api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel
//
// authorised against :boardId and then read the card named by :cardId, with
// nothing tying the two together. canExport() asks "may this user see the board
// in :boardId?" - it never sees :cardId - and _buildAndWrite() then resolved the
// card with a bare primary-key lookup. So the two identifiers came apart: one
// decided the authorisation, the other decided the data.
//
// The precondition is self-service: POST /api/boards takes `permission` straight
// from the request body, so any authenticated user can mint their own PUBLIC
// board, name it as :boardId, and pass the id of a card in somebody else's
// private board as :cardId. The workbook that comes back carries that card's
// title, description, members and assignees, every comment with its author,
// checklists, subtasks, attachment metadata - and, because image attachments are
// read through getReadStream() and embedded with workbook.addImage, the
// attachment BYTES. :listId was never used in any query, so it could be
// anything. One board, reused, made it a scriptable bulk read.
//
// The same route shape for PDF has always been right - ExporterCardPDF resolves
// getCard({ _id, boardId, listId }) - which is what makes this an omission rather
// than a decision, and is why the fix is the PDF exporter's query, not a new
// mechanism.
//
// This suite is a source guard: there is no server here to issue requests to. It
// pins the constrained lookup in both places it now lives, that the route checks
// BEFORE either branch builds (including the public branch, which skips
// authentication entirely), and that the PDF control it copies is still there.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const exporter = read('models/server/ExporterExcelCard.js');
const route = read('models/exportExcelCard.js');
const pdf = read('models/server/ExporterCardPDF.js');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// Declarations only: the file explains the old shape at length, and prose about
// a bare lookup must not read as one.
const code = src => src.split('\n')
.filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l))
.join('\n');
test('the exporter resolves the card INSIDE the authorised board and list', () => {
const body = code(exporter);
assert.ok(/getCard\(\{\s*_id: this\._cardId,\s*boardId: this\._boardId,\s*listId: this\._listId,?\s*\}\)/.test(body),
'the lookup must carry boardId and listId, so a card outside the authorised ' +
'board does not resolve at all');
assert.ok(!/getCard\(this\._cardId\)/.test(body),
'the bare primary-key lookup is the vulnerability itself');
});
test('nothing downstream can be reached when the card does not resolve', () => {
// The fan-out (checklists, checklist items, subtasks, comments, attachments)
// is all keyed on the same card id and carries no board constraint of its own,
// so constraining the CARD is what makes those safe by construction.
const body = code(exporter);
const lookup = body.indexOf('getCard({');
const guard = body.indexOf("res.end('Card not found')");
const fanout = body.indexOf('getChecklists({ cardId: this._cardId })');
assert.ok(lookup !== -1 && guard > lookup,
'the 404 must still follow the lookup');
assert.ok(fanout === -1 || fanout > guard,
'and every query keyed on the card id must come after it');
});
test('the route binds the two identifiers before EITHER branch builds', () => {
const body = code(route);
assert.ok(/getCard\(\{\s*_id: paramCardId,\s*boardId,\s*listId: paramListId,?\s*\}\)/.test(body),
'the route has both identifiers in hand, so it states their relationship');
const check = body.indexOf('_id: paramCardId');
const publicBranch = body.indexOf('board.isPublic()');
const firstBuild = body.indexOf('exporter.build(res)');
assert.ok(check !== -1 && check < publicBranch,
'the PUBLIC branch skips authentication entirely, so the containment check ' +
'has to come before it, not inside the authenticated path');
assert.ok(check < firstBuild, 'and before anything is exported');
});
test('a card that is not in the board is a 404, not a 403', () => {
const body = code(route);
const at = body.indexOf('_id: paramCardId');
const after = body.slice(at, at + 400);
assert.ok(/404/.test(after),
'whether a card id exists at all is not something an unauthorised caller ' +
'should learn from the difference between 403 and 404');
assert.ok(/Card not found/.test(after));
});
test('the PDF exporter this copies is unchanged', () => {
assert.ok(/getCard\(\{\s*\n?\s*_id: this\._cardId,\s*\n?\s*boardId: this\._boardId,\s*\n?\s*listId: this\._listId,?\s*\n?\s*\}\)/.test(code(pdf)),
'it is the control that showed the Excel exporter was an omission; if it ' +
'ever loses the constraint, the same hole opens on the PDF route');
});
test('the two single-card export routes are the only ones of this shape', () => {
// The reporter recommended auditing every /api/boards/:boardId/.../:childId
// route for the same authorize-on-parent / read-on-child mismatch.
const files = ['models/exportExcelCard.js', 'models/exportPDF.js'];
for (const file of files) {
assert.ok(/cards\/:cardId\/export/.test(read(file)),
`${file} should still be a single-card export route`);
}
// The attachment API takes the card id and then checks it against the named
// board - the correct direction, and already in place.
assert.ok(/card\.boardId !== boardId/.test(read('server/attachmentApi.js')),
'the attachment handler already enforces containment; if that check goes, ' +
'the same class of bug reopens there');
});
console.log(`\n${passed} passed`);
+137
View File
@@ -0,0 +1,137 @@
'use strict';
// Guard for wekan/wekan#6582: a compose file that runs FerretDB as a replica set
// must connect to it with directConnection=true.
// Run: node tests/ferretdbDirectConnection.test.cjs
//
// The reported failure, on nothing but `docker compose up -d` with the default
// docker-compose.yml and no change beyond a port and ROOT_URL:
//
// MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
// reason: TopologyDescription { type: 'ReplicaSetNoPrimary',
// servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
// setName: 'rs0', ... }
//
// Note the address: 0.0.0.0, which is in NO compose file. It is FerretDB's
// LISTEN address, and the driver got it from the server.
//
// The ferretdb service runs with `--repl-set-name=rs0` (added for #6480/#6481 so
// Meteor can tail an OpLog instead of poll-and-diff). FerretDB then answers the
// hello handshake as a one-member replica set and fills `hosts`, `me` and
// `primary` with its own --listen-addr, which is the wildcard 0.0.0.0:27017.
// A MongoDB driver that is not in directConnection mode does replica-set
// DISCOVERY on that reply: it adopts the advertised member list and DROPS the
// seed it was given (the "me mismatch" rule - the server says it is called
// something other than what the client dialled). So `mongodb://ferretdb:27017`
// becomes `0.0.0.0:27017`, which inside the wekan container is the wekan
// container itself, where nothing listens. Verified against FerretDB v1.49.0 and
// the mongodb driver the bundle ships:
//
// mongodb://localhost:27017/wekan -> servers: 0.0.0.0:27017 (seed discarded)
// mongodb://localhost:27017/wekan?directConnection=true -> servers: localhost:27017
//
// directConnection=true keeps the driver on exactly the host it was given, and
// costs nothing else: the handshake still carries setName: 'rs0', which is the
// only thing Meteor's oplog tailing checks (packages/mongo/oplog_replica_set_error.js
// accepts the URL when `ismaster.setName` is set), so #6480/#6481 still work.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const composeFiles = fs.readdirSync(repoRoot)
.filter(f => /^docker-compose.*\.ya?ml$/.test(f))
.sort();
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
test('the compose files are found', () => {
assert.ok(composeFiles.length >= 5,
`expected the docker-compose*.yml set, found ${composeFiles.length}`);
assert.ok(composeFiles.includes('docker-compose.yml'), 'the default compose file must be there');
});
// A compose file "runs FerretDB as a replica set" when its ferretdb service is
// started with --repl-set-name. That flag is what makes the handshake advertise
// a replica set at all, and so it is what makes discovery kick in.
function declaresReplSet(text) {
return /--repl-set-name[= ]/.test(text);
}
// Only URLs that point at THIS compose file's own ferretdb service matter. A
// MongoDB service (docker-compose-mongodb-v7.yml, the multitenancy example) has
// a real replica set with a real, resolvable member list, and must keep using it.
function ferretdbUrls(text) {
const urls = [];
for (const line of text.split('\n')) {
const m = line.match(/^\s*#?\s*-\s*(MONGO_URL|MONGO_OPLOG_URL)=(\S+)/);
if (!m) continue;
const [, name, url] = m;
if (!/@?(wekan-)?ferretdb:/.test(url)) continue;
urls.push({ name, url, commented: /^\s*#/.test(line) });
}
return urls;
}
for (const file of composeFiles) {
const text = fs.readFileSync(path.join(repoRoot, file), 'utf8');
if (!declaresReplSet(text)) continue;
const urls = ferretdbUrls(text);
test(`${file}: its ferretdb URLs are found`, () => {
assert.ok(urls.length > 0,
`${file} starts FerretDB with --repl-set-name but has no MONGO_URL pointing at it`);
assert.ok(urls.some(u => u.name === 'MONGO_URL' && !u.commented),
`${file} has no ACTIVE MONGO_URL for its ferretdb service`);
});
test(`${file}: every ferretdb URL carries directConnection=true`, () => {
const missing = urls
.filter(u => !/[?&]directConnection=true(&|$)/.test(u.url))
.map(u => `${u.name}=${u.url}${u.commented ? ' (commented example)' : ''}`);
assert.deepStrictEqual(missing, [],
`${file} runs FerretDB with --repl-set-name, so a driver without ` +
`directConnection=true throws away this host and dials the advertised ` +
`0.0.0.0:27017 instead (#6582). Missing on:\n ` + missing.join('\n '));
});
}
test('a compose file that does NOT run a replica-set FerretDB is left alone', () => {
// FerretDB v2 is not started with --repl-set-name, so its handshake advertises
// no replica set, no discovery happens, and directConnection is not needed.
// Pinned so that adding --repl-set-name there is a decision, not an accident.
const v2 = path.join(repoRoot, 'docker-compose-ferretdb-v2-postgresql.yml');
if (!fs.existsSync(v2)) return;
const text = fs.readFileSync(v2, 'utf8');
if (declaresReplSet(text)) {
const urls = ferretdbUrls(text).filter(u => !/directConnection=true/.test(u.url));
assert.deepStrictEqual(urls.map(u => u.url), [],
'FerretDB v2 gained --repl-set-name, so its URLs need directConnection=true too');
}
});
// The MongoDB compose files must NOT be "fixed" the same way: those are real
// replica sets whose members are configured with resolvable names, and
// directConnection would pin Meteor to one node of them.
test('the MongoDB compose files keep replicaSet= and gain no directConnection', () => {
for (const file of ['docker-compose-mongodb-v7.yml', 'docker-compose-multitenancy.yml']) {
const p = path.join(repoRoot, file);
if (!fs.existsSync(p)) continue;
const text = fs.readFileSync(p, 'utf8');
for (const line of text.split('\n')) {
const m = line.match(/^\s*-\s*(MONGO_URL|MONGO_OPLOG_URL)=(\S+)/);
if (!m) continue;
if (/(wekan-)?ferretdb:/.test(m[2])) continue;
assert.ok(!/directConnection=true/.test(m[2]),
`${file}: ${m[1]} points at a real MongoDB replica set, whose members ARE ` +
`reachable by the names it advertises; directConnection would pin Meteor to ` +
`a single node of it. Line: ${line.trim()}`);
}
}
});
console.log(`\n${passed} passed`);
+201
View File
@@ -0,0 +1,201 @@
'use strict';
// wekan/wekan#6583 "Missing Data after Upgrade from v6 to latest Release":
// after upgrading, WeKan showed "the state of the data from days ago" and the
// reporter suspected a `snap revert` done four weeks earlier.
// Run: node tests/ferretdbMigrationStale.test.cjs
//
// They were right about the cause and it is not data loss. The MongoDB ->
// FerretDB migration copies MongoDB into SQLite ONCE and writes
// .migration-to-ferretdb-done; nothing keeps that copy in step afterwards. Revert
// the snap to a revision that runs mongod and WeKan carries on writing to
// MongoDB - for four weeks, in this report - while the finished SQLite sits
// frozen at the date it was made. Refresh forward again and mongodb-control saw
// a marker plus a non-empty SQLite, called that a completed migration, and
// switched the snap onto it: every board and card written during the revert was
// still on disk and simply not being served.
//
// Nothing was ever in danger. The database lives in $SNAP_COMMON, which is shared
// across revisions and is NOT rolled back by `snap revert` (unlike $SNAP_DATA,
// which is per-revision). What was wrong was WHICH of the two copies got served,
// and nothing compared their ages.
//
// So: never serve a migrated copy that is older than the MongoDB beside it. If
// mongod can run, keep MongoDB, which has the newest data. If mongod CANNOT run
// (the AVX case that forces the migration in the first place), migrate again from
// scratch rather than serve the stale copy.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..');
const stale = path.join(repoRoot, 'snap-src/bin/ferretdb-migration-stale');
const mongodbControl = fs.readFileSync(path.join(repoRoot, 'snap-src/bin/mongodb-control'), 'utf8');
const wekanControl = fs.readFileSync(path.join(repoRoot, 'snap-src/bin/wekan-control'), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
test('the detector exists, is executable, and parses', () => {
assert.ok(fs.existsSync(stale), 'snap-src/bin/ferretdb-migration-stale is missing');
assert.ok(fs.statSync(stale).mode & 0o111, 'it must be executable');
const check = spawnSync('bash', ['-n', stale]);
assert.strictEqual(check.status, 0, `bash -n failed: ${check.stderr}`);
});
// A $SNAP_COMMON as the snap really lays it out: WiredTiger files at the top,
// the FerretDB SQLite under files/db, the marker beside them.
function makeCommon({ mongoAt, migratedAt, sqlite = true, mongo = true, extra = {} }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-common-'));
fs.mkdirSync(path.join(dir, 'files/db'), { recursive: true });
fs.mkdirSync(path.join(dir, 'journal'), { recursive: true });
if (mongo) {
for (const f of ['WiredTiger', '_mdb_catalog.wt', 'collection-0.wt']) {
fs.writeFileSync(path.join(dir, f), 'x');
fs.utimesSync(path.join(dir, f), new Date(mongoAt), new Date(mongoAt));
}
}
if (sqlite) fs.writeFileSync(path.join(dir, 'files/db/wekan.sqlite'), 'data');
if (migratedAt) {
const marker = path.join(dir, '.migration-to-ferretdb-done');
fs.writeFileSync(marker, '');
fs.utimesSync(marker, new Date(migratedAt), new Date(migratedAt));
}
for (const [name, when] of Object.entries(extra)) {
fs.writeFileSync(path.join(dir, name), 'x');
fs.utimesSync(path.join(dir, name), new Date(when), new Date(when));
}
return dir;
}
// Exit 0 = stale. Anything else = current / cannot tell.
function isStale(dir) {
return spawnSync('bash', [stale, dir], { encoding: 'utf8' }).status === 0;
}
function withCommon(opts, fn) {
const dir = makeCommon(opts);
try { return fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
const JULY = '2026-07-14T12:00:00Z';
const AUGUST = '2026-08-11T09:00:00Z';
test('the reported case: MongoDB written to for weeks after the migration', () => {
withCommon({ migratedAt: JULY, mongoAt: AUGUST }, dir => {
assert.strictEqual(isStale(dir), true,
'a migration from four weeks before the newest MongoDB write is behind it, ' +
'and serving it is what showed "the state of the data from days ago"');
const out = spawnSync('bash', [stale, dir], { encoding: 'utf8' }).stderr;
assert.ok(/BEHIND the MongoDB data/.test(out), `it must say so: ${out}`);
assert.ok(/day\(s\) later/.test(out), 'and by how much');
});
});
test('a migration newer than the data is current', () => {
withCommon({ migratedAt: AUGUST, mongoAt: JULY }, dir => {
assert.strictEqual(isStale(dir), false,
'the normal case - migrate, then stop using MongoDB - must not be called stale');
});
});
test('the migration stopping its own source mongod does not count as staleness', () => {
// The migration starts a temporary mongod to read from and stops it again, and
// that shutdown writes to the data directory moments AFTER the marker appears.
// Without a margin every fresh migration would look stale and the snap would
// migrate in a loop.
withCommon({ migratedAt: '2026-08-11T09:56:00Z', mongoAt: '2026-08-11T10:00:00Z' }, dir => {
assert.strictEqual(isStale(dir), false, 'four minutes later is the migration itself');
});
withCommon({ migratedAt: '2026-08-11T09:30:00Z', mongoAt: '2026-08-11T10:00:00Z' }, dir => {
assert.strictEqual(isStale(dir), true, 'half an hour later is somebody using MongoDB');
});
});
test('only DATABASE files count, not a log or a pid file', () => {
// $SNAP_COMMON also holds mongodb.log and mongodb.pid, which starting the snap
// touches. Counting those would call every migration stale on the next boot.
withCommon({
migratedAt: '2026-08-11T09:30:00Z',
mongoAt: '2026-08-11T09:00:00Z',
extra: { 'mongodb.log': '2026-08-11T10:00:00Z', 'mongodb.pid': '2026-08-11T10:00:00Z' },
}, dir => {
assert.strictEqual(isStale(dir), false,
'a newer log file says the snap was started, not that the database was written to');
});
});
test('nothing to compare is never reported as stale', () => {
// Each of these must answer "not stale": being unsure has to leave the existing
// behaviour alone, because the caller acts on a yes.
withCommon({ mongoAt: AUGUST, migratedAt: null }, dir =>
assert.strictEqual(isStale(dir), false, 'no marker: no migration to be behind'));
withCommon({ migratedAt: JULY, mongoAt: AUGUST, sqlite: false }, dir =>
assert.strictEqual(isStale(dir), false,
'the marker is written even when the migration produced no data; an empty ' +
'FerretDB is the OTHER guard\'s business'));
withCommon({ migratedAt: JULY, mongoAt: AUGUST, mongo: false }, dir =>
assert.strictEqual(isStale(dir), false,
'a FerretDB-only install has no MongoDB to have fallen behind'));
assert.strictEqual(isStale(path.join(os.tmpdir(), 'wekan-nonexistent-dir')), false,
'a directory that is not there must not be called stale either');
});
// ── the two places that act on it ───────────────────────────────────────────
test('mongodb-control will not switch to a stale migration', () => {
const at = mongodbControl.indexOf('switch_to_completed_ferretdb() {');
assert.notStrictEqual(at, -1, 'mongodb-control must still have that function');
const body = mongodbControl.slice(at, mongodbControl.indexOf('\n}', at));
assert.ok(/ferretdb-migration-stale/.test(body),
'the switch must consult the check - "a completed migration exists" was the ' +
'whole of its test, and completed is not the same as up to date');
const guard = body.indexOf('ferretdb-migration-stale');
const doSwitch = body.indexOf('snapctl set database=ferretdb');
assert.ok(guard !== -1 && guard < doSwitch,
'and consult it BEFORE switching, not after');
assert.ok(/return 1/.test(body.slice(guard, doSwitch)),
'a stale migration must leave MongoDB running, which has the newest data');
});
test('when mongod cannot run at all, a stale migration is redone rather than served', () => {
const at = mongodbControl.indexOf('handle_mongod_start_failure() {');
const body = mongodbControl.slice(at, mongodbControl.indexOf('\n# #6458', at));
assert.ok(/stale_migration/.test(body),
'keeping MongoDB is not on offer when mongod will not start, so the choice is ' +
'between a fresh copy and a stale one');
for (const cleared of ['.migration-to-ferretdb-done', 'migration-progress.json', 'files/db/']) {
assert.ok(body.includes(cleared),
`a stale migration must be RESTARTED, so ${cleared} has to go - migration-control ` +
'exits immediately while the marker is there, and the checkpoint describes a ' +
'finished copy of the old data');
}
assert.ok(!/rm -rf "\$SNAP_COMMON"\/collection|rm .*WiredTiger/.test(body),
'and the MongoDB data itself must never be touched');
});
test('wekan-control keeps MongoDB when the migrated copy is behind it', () => {
assert.ok(/ferretdb-migration-stale/.test(wekanControl),
'wekan-control guards against an EMPTY FerretDB already; a full but out-of-date ' +
'one looks worse, because WeKan comes up with only the last weeks missing');
const at = wekanControl.indexOf('ferretdb-migration-stale');
const after = wekanControl.slice(at, at + 1600);
assert.ok(/export DATABASE="mongodb"/.test(after) &&
/snapctl set database=mongodb/.test(after),
'it must fall back to MongoDB, which has the newest data');
assert.ok(/snap revert. does not roll back|not rolled back by a revert/.test(after),
'and say that nothing is lost - $SNAP_COMMON is shared across revisions - or the ' +
'message reads as a report of data loss');
});
test('the detector ships inside the snap', () => {
const snapcraft = fs.readFileSync(path.join(repoRoot, 'snapcraft.yaml'), 'utf8');
assert.ok(/helpers:\s*\n\s*source: snap-src\s*\n\s*plugin: dump/.test(snapcraft),
'snap-src is dumped wholesale, which is what puts bin/ferretdb-migration-stale ' +
'in $SNAP/bin - if that part ever stops being a dump, the callers break');
});
console.log(`\n${passed} passed`);
+18 -5
View File
@@ -141,9 +141,18 @@ test('the detail names the operators and nothing else', () => {
// ---------------------------------------------------------------- the wiring
test('the windowed card publication trips a canary on an injected selector', () => {
// GHSA-phm4-4v26-j2vq moved selectorIsInjection out of this publication and into
// /server/lib/selectorGuard, because eight OTHER handlers took the same
// client-supplied selector and never called it. The publication's behaviour is
// unchanged - it imports the same function - so what is pinned here is that the
// definition exists in one place and that both refusal sites still use it.
const src = read('server/publications/cardsWindow.js');
assert.ok(/function selectorIsInjection\(selector, where\)/.test(src));
assert.ok(/tripCanary\('injection\.nosql-selector'/.test(src));
const guard = read('server/lib/selectorGuard.js');
assert.ok(/function selectorIsInjection\(selector, where\)/.test(guard),
'the one definition lives in the shared guard now');
assert.ok(/import \{ selectorIsInjection \} from '\/server\/lib\/selectorGuard'/.test(src),
'and the publication imports it rather than keeping a copy');
assert.ok(/tripCanary\('injection\.nosql-selector'/.test(guard));
// Both refusal sites go through it.
const uses = src.match(/selectorIsInjection\(cardSelector, '/g) || [];
assert.strictEqual(uses.length, 2, 'the window AND the count');
@@ -155,13 +164,17 @@ test('SILENT: the publication still answers exactly as before', () => {
// caller cannot tell the canary is there.
assert.ok(/\? \{ _id: \{ \$in: \[\] \} \}/.test(src));
assert.ok(/if \(!board \|\| selectorIsInjection\(cardSelector, 'boardCardsCount'\)\) \{\s*\n\s*return this\.ready\(\);/.test(src));
assert.ok(/return true;/.test(src.match(/function selectorIsInjection[\s\S]*?\n\}/)[0]),
const guard = read('server/lib/selectorGuard.js');
assert.ok(/return true;/.test(guard.match(/function selectorIsInjection[\s\S]*?\n\}/)[0]),
'and it returns the same boolean hasWhere() did');
});
test('the old $where-only check is still honoured, not replaced', () => {
const src = read('server/publications/cardsWindow.js');
assert.ok(/hasWhere\(selector\)/.test(src),
// Looked for in the shared guard now (GHSA-phm4-4v26-j2vq moved the function
// there); the point is unchanged - hasWhere is the narrower, older check and the
// widened classifier must not quietly replace it.
const guard = read('server/lib/selectorGuard.js');
assert.ok(/hasWhere\(selector\)/.test(guard),
'the narrower existing guard stays: a widened detector must not lose a case');
});
+44 -3
View File
@@ -106,13 +106,54 @@ test('a vertical swipe in those scrollers is a scroll, and stays in them', () =>
}
});
test('mobile mode sizes the body and the content area the same way', () => {
test('the mobile body is exactly one viewport, stated in dvh as well as vh', () => {
// `position: fixed; height: 100vh` on the body has the same problem, and it is
// what the whole mobile layout hangs from.
const body = layouts.slice(layouts.indexOf('body.mobile-mode {'));
assert.ok(/height: 100vh;[\s\S]{0,400}height: 100dvh;/.test(body));
const content = layouts.slice(layouts.indexOf('body.mobile-mode #content {'));
assert.ok(/height: calc\(100vh - 48px\);\s*\n\s*height: calc\(100dvh - 48px\);/.test(content));
});
test('#content is sized by the flex column, not by a guess at the header height', () => {
// CHANGED DELIBERATELY, and the change is the fix for #6488 rather than a
// relaxation of this guard. This used to REQUIRE
// height: calc(100vh - 48px); height: calc(100dvh - 48px);
// and getting the dvh fallback right there was the whole point - but the rule
// was wrong in a way no dvh can repair: `- 48px` is a guess at how tall the
// header is, and the header is not 48px and is not any one number. Utils
// publishes --wekan-header-height from a ResizeObserver precisely because the
// quick-access bar wraps to a second and third row depending on language and
// width, and its comment says every fixed number for it has been wrong.
//
// On a phone with a wrapped header, #content was therefore TALLER than the room
// under it, so its bottom sat below the screen - and body.mobile-mode is
// `position: fixed` and `overflow: hidden`, so that strip is unreachable. The
// `height: 100%` chain below it inherited the error and the last boards ended
// up where no gesture could reach: "at smartphone, at All Boards page, it is
// not possible to scroll down to see remaining of boards."
//
// body is a flex column of exactly one viewport and #content is its `flex: 1`
// item, so the space under the header ALREADY is this box, at whatever height
// the header really is. So what is pinned now is the absence of the arithmetic.
const contentAt = layouts.indexOf('body.mobile-mode #content {');
assert.notStrictEqual(contentAt, -1, 'layouts.css has no body.mobile-mode #content rule');
// Comments stripped first: the rule explains at length what it used to say,
// and quoting the old declaration must not read as still declaring it.
const content = layouts.slice(contentAt, layouts.indexOf('}', contentAt))
.replace(/\/\*[\s\S]*?\*\//g, '');
assert.ok(!/height:\s*calc\(/.test(content),
'body.mobile-mode #content must not compute a height from the viewport: a ' +
'fixed number for the header is what put the bottom of the page off screen');
assert.ok(/min-height:\s*0;/.test(content),
'it must be allowed to be shorter than its content, or the inner scroller ' +
'below it can never scroll');
// ...and the flex item it depends on is really there.
const base = layouts.slice(layouts.indexOf('\n#content {'));
assert.ok(/flex:\s*1;/.test(base.slice(0, base.indexOf('}'))),
'#content must stay the flex: 1 item of the body column - that is what now ' +
'gives it the height the calc() was guessing at');
const bodyRule = layouts.slice(layouts.indexOf('\nbody {'));
assert.ok(/flex-direction:\s*column;/.test(bodyRule.slice(0, bodyRule.indexOf('}'))),
'and body must stay a flex column, or #content has no share to take');
});
test('the board tiles still drag from their handle only, on a touch screen', () => {
+123
View File
@@ -0,0 +1,123 @@
'use strict';
// #6533 — FerretDB at 737% CPU and constant SQLITE_BUSY on a snap instance.
// Run: node tests/notificationCap.test.cjs
//
// The reporter's logs name the write every time:
//
// [db-retry] the database was busy: 0 write(s) retried, 0 succeeded on a retry,
// 3 given up on.
// ValidationError: ... [collection.go:191 sqlite.(*collection).UpdateAll]
// database is locked (5) (SQLITE_BUSY)
// at _helpersConstructor.addNotification (models/users.js:2248)
//
// addNotification is `$addToSet` on `profile.notifications` — an array INSIDE the
// user document. Adding one means reading the whole document, scanning the array
// for a duplicate and writing the whole document back, so the cost grows with the
// array. FerretDB on SQLite has a single writer, so those rewrites queue behind
// each other, start failing with SQLITE_BUSY, and login — which also writes to the
// user document — queues behind them. That is the slow login and the pinned CPU.
//
// WHY THE ARRAY GROWS FOREVER: the existing cleanup (#5685) only removes
// notifications that have been READ and are past their removal age. A user who
// never clears their tray accumulates entries with no upper bound at all, and
// nothing in the product ever shrinks it.
//
// So the cleanup now also CAPS the array. This suite pins the cap's decisions,
// which is where the judgement is: what to keep, what never to touch, and that a
// user needing no change is not written to at all.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const {
cappedNotifications,
notificationCapFromEnv,
expiredNotificationActivityIds,
} = require(path.join(repoRoot, 'models/lib/notificationCleanup.js'));
const serverSrc = fs.readFileSync(
path.join(repoRoot, 'server/models/users.js'), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
const notif = (n, read = null) => ({ activity: `a${n}`, read });
test('an array under the cap is left completely alone', () => {
const list = [notif(1), notif(2), notif(3)];
assert.strictEqual(cappedNotifications(list, 10), null,
'null means "nothing to do" - the caller must then skip the write entirely, ' +
'because rewriting the array with itself is exactly the write this is about');
assert.strictEqual(cappedNotifications(list, 3), null, 'exactly at the cap is not over it');
});
test('an oversized array keeps the NEWEST entries', () => {
const list = [notif(1), notif(2), notif(3), notif(4), notif(5)];
const kept = cappedNotifications(list, 2);
assert.deepStrictEqual(kept.map(n => n.activity), ['a4', 'a5'],
'$addToSet appends, so the tail is the newest - and an unread notification ' +
'from long ago is not something anyone is going to act on');
assert.strictEqual(kept.length, 2);
});
test('unread entries are capped too - that is the whole point', () => {
// The existing rule cannot touch these: it only removes READ ones.
const unread = Array.from({ length: 50 }, (_, i) => notif(i));
assert.deepStrictEqual(expiredNotificationActivityIds(unread, 2, new Date()), [],
'confirming the existing rule leaves every unread entry in place');
const kept = cappedNotifications(unread, 10);
assert.strictEqual(kept.length, 10, 'so the cap has to be what bounds them');
});
test('junk input never produces a write', () => {
for (const bad of [null, undefined, 'nope', 42, {}]) {
assert.strictEqual(cappedNotifications(bad, 10), null,
`${JSON.stringify(bad)} must be left alone rather than turned into an array`);
}
assert.strictEqual(cappedNotifications([notif(1), notif(2)], 0), null,
'a cap of 0 disables capping instead of deleting everything');
assert.strictEqual(cappedNotifications([notif(1), notif(2)], -5), null,
'and so does a negative one');
});
test('the default cap is generous, and configurable', () => {
assert.strictEqual(notificationCapFromEnv({}), 1000,
'this is a backstop against unbounded growth, not a retention policy');
assert.strictEqual(notificationCapFromEnv({ NOTIFICATION_TRAY_MAX_PER_USER: '50' }), 50);
for (const bad of ['', 'abc', '0', '-1', undefined]) {
assert.strictEqual(notificationCapFromEnv({ NOTIFICATION_TRAY_MAX_PER_USER: bad }), 1000,
`a nonsense setting (${JSON.stringify(bad)}) must fall back to the default, ` +
'not disable the backstop');
}
});
// ── how the cleanup uses it ─────────────────────────────────────────────────
test('the cap is applied to what is left AFTER the expiry pull', () => {
assert.ok(/const remaining = activityIds\.length/.test(serverSrc),
'capping the array before the $pull would trim entries that were about to be ' +
'removed anyway, and keep fewer than the cap');
assert.ok(/cappedNotifications\(remaining, maxPerUser\)/.test(serverSrc));
});
test('a user who needs neither is not written to', () => {
assert.ok(/if \(activityIds\.length === 0 && kept === null\) continue;/.test(serverSrc),
'the whole problem is writes to the user document; the cleanup must not add ' +
'one per user per day for users with nothing to change');
});
test('it stays ONE write per user', () => {
const at = serverSrc.indexOf('const maxPerUser = notificationCapFromEnv');
const body = serverSrc.slice(at, serverSrc.indexOf('const startNotificationCleanup', at));
const updates = (body.match(/await Users\.updateAsync\(/g) || []).length;
assert.strictEqual(updates, 1,
'#5685 collapsed K writes per user into one; the cap must not undo that by ' +
'adding a second update alongside the $pull');
assert.ok(/\$set: \{ 'profile\.notifications': kept \}/.test(body),
'the capped case replaces the array outright - a $pull would have to list the ' +
'entries it removes, and there being too many of them is the problem');
});
console.log(`\n${passed} passed`);
+317
View File
@@ -0,0 +1,317 @@
'use strict';
// Guard for releases/reindex-charts.py - the tool that rebuilds the wekan/charts
// Helm index from the packages that repository actually serves.
// Run: node tests/reindexCharts.test.cjs
//
// The index had drifted from its own packages in four ways at once, and every
// one of them came from the same habit: release-charts.sh writes an entry by
// COPYING the previous entry and substituting a few fields, so the index was
// being treated as the record rather than as a description of the .tgz files
// beside it.
//
// 146 packages had no entry at all - downloadable by URL, invisible to
// `helm search`, `helm install --version` and Artifact Hub.
// 9.36.0 had four entries and 10.30.0 two, each with a different digest and
// the same url, so a helm client could pick a digest that matches no file
// and fail integrity on a good package.
// appVersion was the CHART version ("10.79.0") on every entry, where the
// package says the WeKan version ("10.79").
// Every entry claimed mongodb 0.7.2 because nobody substituted that field,
// while the packages had moved to 0.7.6.
//
// So the rebuild reads each package's own Chart.yaml out of the archive. The
// rules that matter are checked here by running the tool against a synthesised
// charts directory: it must never invent a `created`, never drop a version, and
// never write an index in which one version means two files.
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const zlib = require('zlib');
const repoRoot = path.resolve(__dirname, '..');
const script = path.join(repoRoot, 'releases/reindex-charts.py');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// python3 with PyYAML is what the tool needs; without it the behaviour tests
// cannot run, and saying so is better than reporting a pass that did not happen.
const hasPyYaml = spawnSync('python3', ['-c', 'import yaml'], { stdio: 'ignore' }).status === 0;
test('the tool exists and is executable', () => {
assert.ok(fs.existsSync(script), 'releases/reindex-charts.py is missing');
assert.ok(fs.statSync(script).mode & 0o111, 'reindex-charts.py must be executable');
});
test('it writes nothing unless asked', () => {
const src = fs.readFileSync(script, 'utf8');
assert.ok(/if not args\.write:/.test(src),
'the report must return before index.yaml is written');
const gate = src.indexOf('if not args.write:');
const write = src.indexOf('yaml.safe_dump(document, fh');
assert.ok(gate !== -1 && write > gate, 'nothing may be written before the --write gate');
assert.ok(!/git (push|commit)/.test(src),
'rebuilding an index is not the same as publishing it - pushing stays a ' +
'separate, deliberate step');
});
test('it looks for the charts checkout where the repo keeps companion repos', () => {
const src = fs.readFileSync(script, 'utf8');
assert.ok(/\.tools", "charts"/.test(src),
'.tools/charts is where companion repositories are cloned (CLAUDE.md)');
assert.ok(/"w", "charts"/.test(src), '../w/charts must keep working as the older location');
const sh = fs.readFileSync(path.join(repoRoot, 'releases/backfill-charts.sh'), 'utf8');
assert.ok(/\$REPO_DIR\/\.tools\/charts/.test(sh),
'backfill-charts.sh must look in the same places');
});
// ── the behaviour, run for real against a synthesised charts directory ──────
// A minimal but REAL chart package: a gzipped tar with <name>/Chart.yaml in it,
// which is what the tool reads. Written by hand so the test needs no helm.
function makePackage(dir, filename, chart) {
const body = Buffer.from(
`apiVersion: v2\nname: wekan\nversion: ${chart.version}\n` +
`appVersion: "${chart.appVersion}"\ndescription: Open Source kanban\n` +
(chart.dependencies
? `dependencies:\n- name: mongodb\n version: ${chart.dependencies}\n` : ''),
'utf8');
// One ustar header + the file, padded to 512-byte records.
const header = Buffer.alloc(512);
header.write('wekan/Chart.yaml', 0, 'utf8');
header.write('0000644\0', 100);
header.write('0000000\0', 108);
header.write('0000000\0', 116);
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124);
header.write('00000000000\0', 136);
header.write(' ', 148); // checksum field, spaces while summing
header.write('0', 156);
header.write('ustar\0', 257);
header.write('00', 263);
let sum = 0;
for (const byte of header) sum += byte;
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
const tar = Buffer.concat([header, body, pad, Buffer.alloc(1024)]);
fs.writeFileSync(path.join(dir, filename), zlib.gzipSync(tar));
return crypto.createHash('sha256').update(fs.readFileSync(path.join(dir, filename))).digest('hex');
}
function writeIndex(dir, entries) {
const body = entries.map(e =>
` - apiVersion: v2\n appVersion: "${e.appVersion}"\n` +
` created: "${e.created}"\n digest: ${e.digest}\n name: wekan\n` +
` urls:\n - https://wekan.github.io/charts/wekan-${e.version}.tgz\n` +
` version: ${e.version}\n`).join('');
fs.writeFileSync(path.join(dir, 'index.yaml'),
`apiVersion: v1\nentries:\n wekan:\n${body}generated: "2026-01-01T00:00:00+00:00"\n`);
}
function run(dir, extra = []) {
return execFileSync('python3', [script, '--charts-dir', dir, ...extra],
{ encoding: 'utf8' });
}
function readIndex(dir) {
const text = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
const out = [];
let current = null;
for (const line of text.split('\n')) {
if (/^ {2}- /.test(line)) { current = {}; out.push(current); }
if (!current) continue;
const m = line.match(/^\s+(appVersion|created|digest|version):\s*'?"?([^'"\n]*)'?"?\s*$/);
if (m) current[m[1]] = m[2];
}
return out;
}
function withCharts(fn) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-reindex-'));
try { return fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
if (!hasPyYaml) {
console.log(' -- python3 PyYAML not available; behaviour tests skipped');
} else {
test('a package with no entry is added, and one already correct keeps its created', () => {
withCharts(dir => {
const dA = makePackage(dir, 'wekan-10.79.0.tgz', { version: '10.79.0', appVersion: '10.79' });
makePackage(dir, 'wekan-9.63.0.tgz', { version: '9.63.0', appVersion: '9.63' });
writeIndex(dir, [
{ version: '10.79.0', appVersion: '10.79.0', created: '2026-08-10T02:46:52.308983+00:00', digest: dA },
]);
const out = run(dir, ['--write']);
const entries = readIndex(dir);
assert.deepStrictEqual(entries.map(e => e.version), ['10.79.0', '9.63.0'],
'the unindexed package must be added, newest first');
assert.strictEqual(entries[0].created, '2026-08-10T02:46:52.308983+00:00',
'an entry whose digest matches its package must keep the created it has - ' +
'a rebuild must not restamp the whole repository');
assert.ok(entries[1].created, 'the added entry must still have a created');
assert.ok(/added\s+:\s*1/.test(out), out);
});
});
test('appVersion is taken from the package, not from the chart version', () => {
withCharts(dir => {
const d = makePackage(dir, 'wekan-10.79.0.tgz', { version: '10.79.0', appVersion: '10.79' });
writeIndex(dir, [
{ version: '10.79.0', appVersion: '10.79.0', created: '2026-08-10T02:46:52+00:00', digest: d },
]);
run(dir, ['--write']);
assert.strictEqual(readIndex(dir)[0].appVersion, '10.79',
'appVersion is the WeKan version the package declares; the index had the ' +
'CHART version there on every entry');
});
});
test('duplicate entries collapse to the one whose digest matches the package', () => {
withCharts(dir => {
const real = makePackage(dir, 'wekan-9.36.0.tgz', { version: '9.36.0', appVersion: '9.36' });
// The matching digest on the OLDEST copy: if "newest wins" were the rule
// rather than a fallback, this is what would expose it.
writeIndex(dir, [
{ version: '9.36.0', appVersion: '9.36.0', created: '2026-06-11T03:41:02+00:00', digest: 'b'.repeat(64) },
{ version: '9.36.0', appVersion: '9.36.0', created: '2026-06-11T02:26:50+00:00', digest: 'c'.repeat(64) },
{ version: '9.36.0', appVersion: '9.36.0', created: '2026-06-10T21:20:06+00:00', digest: real },
]);
run(dir, ['--write']);
const entries = readIndex(dir);
assert.strictEqual(entries.length, 1, 'one entry per version');
assert.strictEqual(entries[0].digest, real,
'the surviving entry must be the one a helm client can verify the file against');
assert.strictEqual(entries[0].created, '2026-06-10T21:20:06+00:00',
'and its created comes with it - the digest decides, not the timestamp');
});
});
test('two packages claiming one version: the filename that matches wins, and it is reported', () => {
withCharts(dir => {
makePackage(dir, 'wekan-6.9.6.tgz', { version: '6.9.6', appVersion: '6.96' });
makePackage(dir, 'wekan-6.96.tgz', { version: '6.9.6', appVersion: '6.96' });
writeIndex(dir, []);
const out = run(dir, ['--write']);
const entries = readIndex(dir);
assert.strictEqual(entries.length, 1,
'an index where one version means two files cannot say which to install');
const text = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
assert.ok(text.includes('wekan-6.9.6.tgz'), 'the file whose name matches its version is kept');
assert.ok(!text.includes('wekan-6.96.tgz'), 'the mis-named one is not offered as a version');
assert.ok(/not offered/.test(out) && /wekan-6\.96\.tgz/.test(out),
`dropping a package must be reported, not silent. Got:\n${out}`);
});
});
test('no version that was listed before is ever dropped', () => {
withCharts(dir => {
const d = makePackage(dir, 'wekan-8.00.0.tgz', { version: '8.00.0', appVersion: '8.00' });
makePackage(dir, 'wekan-7.99.0.tgz', { version: '7.99.0', appVersion: '7.99' });
writeIndex(dir, [
{ version: '8.00.0', appVersion: '8.00.0', created: '2026-01-02T00:00:00+00:00', digest: d },
]);
run(dir, ['--write']);
const versions = readIndex(dir).map(e => e.version);
assert.ok(versions.includes('8.00.0'), 'a version listed before must still be listed');
assert.ok(versions.includes('7.99.0'), 'and the package that was missing is added');
});
});
test('every entry it writes points at a package that is there, with that package\'s digest', () => {
withCharts(dir => {
const digests = {
'10.79.0': makePackage(dir, 'wekan-10.79.0.tgz', { version: '10.79.0', appVersion: '10.79' }),
'10.78.0': makePackage(dir, 'wekan-10.78.0.tgz', { version: '10.78.0', appVersion: '10.78' }),
};
writeIndex(dir, []);
run(dir, ['--write']);
for (const e of readIndex(dir)) {
assert.strictEqual(e.digest, digests[e.version],
`${e.version}: the digest must be the sha256 of the file being served`);
assert.ok(fs.existsSync(path.join(dir, `wekan-${e.version}.tgz`)),
`${e.version}: an entry must not point at a package that is not there`);
}
});
});
test('a report run leaves index.yaml exactly as it was', () => {
withCharts(dir => {
makePackage(dir, 'wekan-9.63.0.tgz', { version: '9.63.0', appVersion: '9.63' });
writeIndex(dir, []);
const before = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
const out = run(dir);
assert.strictEqual(fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8'), before,
'without --write the file must not be touched at all');
assert.ok(/Report only\. Nothing was written\./.test(out), out);
});
});
}
// ── charts that cannot be installed ─────────────────────────────────────────
//
// Backfilling 146 old packages taught this the hard way. Artifact Hub scans
// EVERY entry in the index and mailed a list of errors within the hour:
//
// error scanning image ghcr.io/wekan/wekan:v9.62: image not found
// error scanning image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found
//
// A chart is a pointer to container images; one whose images are gone installs
// and then fails at the pull, so listing it tells everyone the repository is
// broken when the repository is fine. 135 of the 360 packages are in that state
// - six WeKan images that were never pushed (the releases whose docker job
// failed) and 129 older charts vendoring the Bitnami mongodb subchart, whose
// pinned tags Bitnami has deleted.
test('the exclusion list is a file in the repository, not a decision made per run', () => {
const src = fs.readFileSync(script, 'utf8');
assert.ok(/UNINDEXED_FILE = "unindexed\.txt"/.test(src),
'which packages are not listed must be RECORDED, so a rebuild during a ' +
'release does not depend on reaching two registries - a network blip would ' +
'otherwise drop half the index');
assert.ok(/def read_unindexed\(/.test(src), 'and read back on every run');
assert.ok(/--check-images/.test(src),
'with an explicit flag to regenerate it, rather than doing it implicitly');
});
test('an image that cannot be checked is never treated as missing', () => {
const src = fs.readFileSync(script, 'utf8');
assert.ok(/never "missing"/.test(src),
'image_exists must distinguish "gone" from "could not tell"');
assert.ok(/if verdicts\.get\(\(h, r, t\)\) is False/.test(src),
'only a definite False may exclude a chart - None must not, or a registry ' +
'hiccup silently unpublishes charts');
});
test('the registry handshake is the registry\'s own, not a guess per host', () => {
const src = fs.readFileSync(script, 'utf8');
assert.ok(/WWW-Authenticate/.test(src),
'a hard-coded token URL per registry reported every quay.io image as missing, ' +
'including quay.io/wekan/wekan:latest - the 401 challenge says which realm ' +
'and scope to use');
assert.ok(/localhost/.test(src) && /"\." in head/.test(src),
'and a repository that already begins with a host must not be read as a ' +
'docker.io path - that is how ghcr.io/wekan/wekan came back unknown');
});
test('publishing refuses a version with no container image', () => {
const rel = fs.readFileSync(path.join(repoRoot, 'releases/release-charts.sh'), 'utf8');
assert.ok(/does not exist/.test(rel) && /image pull/.test(rel),
'release-charts.sh must not publish a chart pointing at an image that is not ' +
'there - that is exactly what produced the Artifact Hub scan errors');
const guard = rel.indexOf('ghcr.io/wekan/wekan');
const work = rel.indexOf('git checkout main');
assert.ok(guard !== -1 && guard < work,
'and it must check BEFORE it starts changing the charts repository');
assert.ok(/continuing\./.test(rel),
'a registry it cannot reach must not block a release either');
});
console.log(`\n${passed} passed`);
+231
View File
@@ -0,0 +1,231 @@
'use strict';
// Guard: the armv6 bundle is assembled in a userland its own Node.js can run in,
// and the base-image preflight compares the ARM VARIANT rather than just "arm".
// Run: node tests/releaseArmv6Container.test.cjs
//
// What this pins, from the v10.80 release run:
//
// qemu-arm: Could not open '/lib/ld-linux-armhf.so.3': No such file or directory
// install-node-for-arch.sh: the Node.js binary for armv6 does not run in a
// armv6 container.
//
// The matrix asked for `platform: linux/arm/v6` against `debian:trixie`. DEBIAN
// HAS NO ARMv6 PORT - that image publishes arm/v5 and arm/v7 only - and
// containerd treats a LOWER ARM variant as compatible, so the request quietly
// resolved to arm/v5: Debian armel, ARMv5 SOFT-float, which has no hard-float
// loader in it at all. node-armv6 is hard-float, so it could not start, and the
// job found out 400 lines in, after the whole apt install.
//
// Two things went wrong and both are checked here:
// 1. The preflight (check-arch-binaries.sh) matched on `"architecture": "arm"`
// and ignored the variant, so "debian:trixie has no linux/arm/v6" was never
// the answer it gave.
// 2. armv6 was pointed at a platform that does not exist. It is built in
// Debian's arm/v7 (armhf) container instead: same hard-float ABI, same
// loader, so node-armv6 runs. Nothing ARMv7 reaches the bundle - the
// container's npm install compiles nothing, and node/ferretdb/the MongoDB
// tools are all downloaded already built for ARMv6 - EXCEPT the bundled
// qemu-user, which is copied out of the container, and is therefore gated.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const workflow = fs.readFileSync(
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
const preflight = fs.readFileSync(
path.join(repoRoot, 'releases/check-arch-binaries.sh'), 'utf8');
const installNode = fs.readFileSync(
path.join(repoRoot, 'releases/install-node-for-arch.sh'), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// The platforms debian:trixie actually publishes, as `docker manifest inspect`
// reports them. arm/v6 is deliberately absent: that is the fact this is about.
const DEBIAN_TRIXIE = new Set([
'linux/386', 'linux/amd64', 'linux/arm/v5', 'linux/arm/v7', 'linux/arm64',
'linux/ppc64le', 'linux/riscv64', 'linux/s390x',
]);
// The build-extra-arches matrix entries, read out of the workflow text.
function matrixEntries() {
const start = workflow.indexOf('\n build-extra-arches:\n');
assert.notStrictEqual(start, -1, 'release-all.yml has no build-extra-arches job');
const rest = workflow.slice(start + 1);
const next = rest.search(/\n [a-z0-9-]+:\n/);
const body = next === -1 ? rest : rest.slice(0, next);
const entries = [];
let current = null;
for (const line of body.split('\n')) {
const first = line.match(/^\s*- (arch):\s*(\S+)\s*$/);
if (first) {
current = { arch: first[2] };
entries.push(current);
continue;
}
if (!current) continue;
const kv = line.match(/^\s{12}([a-z_]+):\s*(\S+)\s*$/);
if (kv) current[kv[1]] = kv[2];
else if (/^\s{8,10}[a-z_-]+:/.test(line) && !/^\s{12}/.test(line)) current = null;
}
return entries;
}
const entries = matrixEntries();
test('the build-extra-arches matrix was parsed', () => {
assert.ok(entries.length >= 8, `expected the exotic-arch matrix, got ${entries.length} entries`);
for (const arch of ['armv6', 'armhf', 'armv7', 'i386', 's390x']) {
assert.ok(entries.some(e => e.arch === arch), `matrix has no ${arch} entry`);
}
});
// A platform the base image simply does not have (loong64: no debian, no ubuntu,
// at any tag) is NOT this failure. Docker says "no matching manifest", the
// preflight turns that into a warning, and a best-effort arch skips - which is
// exactly what loong64 does every release, on purpose. 32-bit ARM is the case
// that does not fail cleanly: containerd accepts a LOWER variant as compatible,
// so the request succeeds against the wrong userland and nothing says so.
test('no 32-bit ARM entry names an arm variant its base image does not publish', () => {
const bad = entries
.filter(e => e.image === 'debian:trixie' && e.platform.startsWith('linux/arm/'))
.filter(e => !DEBIAN_TRIXIE.has(e.platform))
.map(e => `${e.arch}: ${e.image} publishes no ${e.platform}, so the request ` +
`resolves DOWNWARDS to a lower ARM variant and the bundle is built ` +
`in the wrong userland`);
assert.deepStrictEqual(bad, [], 'silent ARM variant downgrade:\n ' + bad.join('\n '));
});
test('loong64 is still the knowingly-absent, cleanly-skipped platform (not ARM-style silent)', () => {
const loong = entries.find(e => e.arch === 'loong64');
assert.ok(loong, 'matrix has no loong64 entry');
assert.strictEqual(DEBIAN_TRIXIE.has(loong.platform), false,
'debian:trixie publishes no linux/loong64 - if that changes, this test is the note ' +
'to stop treating loong64 as unbuildable');
assert.strictEqual(loong.optional, 'true',
'loong64 must stay best-effort: the preflight warns and skips it, and a hard ' +
'failure there fails the whole matrix job and takes docker down with it');
});
test('armv6 builds in the armhf (arm/v7) container, which has the hard-float loader', () => {
const armv6 = entries.find(e => e.arch === 'armv6');
assert.strictEqual(armv6.platform, 'linux/arm/v7',
'armv6 must build in Debian\'s arm/v7 (armhf) container: it is the hard-float ' +
'userland node-armv6 needs. linux/arm/v6 does not exist for debian and silently ' +
'resolves to arm/v5 (armel, soft-float), where node-armv6 cannot start.');
assert.strictEqual(armv6.node_arch, 'armv6',
'the Node.js SHIPPED is still ARMv6 - only the container it runs in is armhf');
assert.strictEqual(armv6.ferretdb_arch, 'armv6',
'the FerretDB shipped is still the GOARM=6 build');
});
test('armv6 is marked as a container that is not its own CPU, so no qemu-user is copied out', () => {
const armv6 = entries.find(e => e.arch === 'armv6');
assert.strictEqual(armv6.same_arch_container, 'false',
'armv6 builds in an armv7l container, so same_arch_container must be false: the ' +
'bundled qemu-user is the one artifact copied FROM the container, and an ARMv7 ' +
'qemu-armv7l in an ARMv6 bundle is a binary a Raspberry Pi 1 cannot execute');
const sameArch = entries.filter(e => e.same_arch_container === 'false').map(e => e.arch);
assert.deepStrictEqual(sameArch, ['armv6'],
'only armv6 is built in a foreign-CPU container; every other arch has one of its own');
});
test('the docker run passes SAME_ARCH_CONTAINER into the container', () => {
assert.ok(/-e SAME_ARCH_CONTAINER='\$\{\{ matrix\.same_arch_container \}\}'/.test(workflow),
'the matrix flag has to reach install-node-for-arch.sh to have any effect');
});
test('install-node-for-arch.sh skips bundling qemu-user when the container is not the target CPU', () => {
assert.ok(/SAME_ARCH_CONTAINER:-true/.test(installNode),
'install-node-for-arch.sh must read SAME_ARCH_CONTAINER, defaulting to true so ' +
'every same-arch build keeps bundling its qemu-user (#6458)');
const gate = installNode.indexOf('SAME_ARCH_CONTAINER:-true');
const qemuCopy = installNode.indexOf('qemu-user-static');
assert.ok(gate !== -1 && qemuCopy > gate,
'the gate must come BEFORE the qemu-user install/copy, or it copies it anyway');
});
test('the base-image preflight compares the ARM variant, not just the architecture', () => {
assert.ok(/want_variant=/.test(preflight),
'check-arch-binaries.sh must extract the variant from the platform string');
assert.ok(!/grep -q .*"architecture": \*"\$\{want_arch\}"/.test(preflight),
'the architecture-only grep is what let linux/arm/v6 through against an image ' +
'that publishes only arm/v5 and arm/v7');
assert.ok(/p\.get\("variant", ""\) == want_variant/.test(preflight),
'a REQUESTED variant must be compared exactly - arm/v6 and arm/v5 are different ' +
'userlands, not near neighbours');
});
// The preflight's platform match is small enough to run here directly, against
// the manifest shapes it really sees: debian:trixie's index, an arm64 index
// (published as arm64/v8 but always written linux/arm64), and a single-platform
// image with no manifest list at all.
test('the preflight platform match answers correctly for the manifests it meets', () => {
const { execFileSync } = require('child_process');
const os = require('os');
const script = preflight
.split("if ! WANT_ARCH=\"$want_arch\" WANT_VARIANT=\"$want_variant\" python3 - <<'PYEOF'\n")[1]
.split('\nPYEOF\n')[0];
assert.ok(script.includes('want_variant'), 'could not extract the preflight python block');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-preflight-'));
const scriptPath = path.join(tmp, 'match.py');
fs.writeFileSync(scriptPath, script);
const manifestPath = '/tmp/manifest.json';
const saved = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath) : null;
function matches(manifest, platform) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
const [, arch, variant = ''] = platform.split('/');
try {
execFileSync('python3', [scriptPath], {
env: { ...process.env, WANT_ARCH: arch, WANT_VARIANT: variant },
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
const debian = {
manifests: [...DEBIAN_TRIXIE].map(p => {
const [, architecture, variant] = p.split('/');
return { platform: { os: 'linux', architecture, ...(variant ? { variant } : {}) } };
}),
};
const arm64Only = {
manifests: [{ platform: { os: 'linux', architecture: 'arm64', variant: 'v8' } }],
};
const singlePlatform = { architecture: 'arm', variant: 'v6', os: 'linux' };
try {
// The bug: debian:trixie has arm/v5 and arm/v7, and MUST NOT answer yes to arm/v6.
assert.strictEqual(matches(debian, 'linux/arm/v6'), false,
'debian:trixie publishes no linux/arm/v6 - saying it does is the whole failure');
assert.strictEqual(matches(debian, 'linux/arm/v7'), true, 'debian:trixie has arm/v7');
assert.strictEqual(matches(debian, 'linux/arm/v5'), true, 'debian:trixie has arm/v5');
assert.strictEqual(matches(debian, 'linux/386'), true, 'debian:trixie has 386');
assert.strictEqual(matches(debian, 'linux/loong64'), false, 'debian:trixie has no loong64');
// linux/arm64 is written without the /v8 everyone's registry records.
assert.strictEqual(matches(arm64Only, 'linux/arm64'), true,
'an unrequested variant matches anything, or every linux/arm64 build breaks');
// An image with no manifest list still answers for its own platform.
assert.strictEqual(matches(singlePlatform, 'linux/arm/v6'), true,
'a single-platform image names its architecture in its own config');
assert.strictEqual(matches(singlePlatform, 'linux/arm/v7'), false,
'and it must not answer for a variant it is not');
} finally {
if (saved !== null) fs.writeFileSync(manifestPath, saved);
else fs.rmSync(manifestPath, { force: true });
fs.rmSync(tmp, { recursive: true, force: true });
}
});
console.log(`\n${passed} passed`);
+103
View File
@@ -0,0 +1,103 @@
'use strict';
// Guard: the jobs that run AFTER the release is already published cannot be
// skipped by a cancellation somewhere else in the run.
// Run: node tests/releaseCancelSafeJobs.test.cjs
//
// v10.80 is the case this exists for. Everything shipped - the tag, the GitHub
// release, every bundle, the multi-arch image - and then `charts`, `ucs` and
// `nextcloud` were all SKIPPED, in the same second the `docker` job they wait on
// finished. Nothing about them had failed. build-mac-x64 was queued on `macos-13`,
// a runner label GitHub had retired, so it sat there for nearly two hours and was
// cancelled by hand; a cancelled job cancels the RUN, and a cancelling run SKIPS
// every job that has not started yet. The Helm chart for a published WeKan was
// never pushed, and no ::error:: anywhere said why.
//
// A job whose `if` is `always()` still runs while a run is cancelling. So these
// three say `always() && <the needs that actually matter> == 'success'`: the
// condition is then explicit about the only thing that was ever required (the
// image is on the registries before the chart points at it), instead of being
// implied by a default that a cancellation elsewhere quietly overrides.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const workflow = fs.readFileSync(
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
function job(name) {
const start = workflow.indexOf(`\n ${name}:\n`);
assert.notStrictEqual(start, -1, `release-all.yml has no ${name} job`);
const rest = workflow.slice(start + 1);
const next = rest.search(/\n [a-z0-9-]+:\n/);
return next === -1 ? rest : rest.slice(0, next);
}
// The `if:` of a job, i.e. the one at job level (two-space indent), not a step's.
function jobIf(name) {
const m = job(name).match(/^ {4}if:\s*(.+)$/m);
return m ? m[1].trim() : null;
}
// The `needs:` of a job, in either the inline-list or the bare-scalar form.
function jobNeeds(name) {
const body = job(name);
const inline = body.match(/^ {4}needs:\s*\[([^\]]+)\]/m);
if (inline) return inline[1].split(',').map(s => s.trim());
const scalar = body.match(/^ {4}needs:\s*(\S+)\s*$/m);
if (scalar) return [scalar[1]];
const block = body.match(/^ {4}needs:\s*\n((?: {6}- .+\n)+)/m);
if (block) return block[1].trim().split('\n').map(s => s.replace(/^\s*-\s*/, '').trim());
return [];
}
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// Every job that runs after the release is published and must survive a
// cancellation elsewhere in the run.
const POST_RELEASE_JOBS = ['charts', 'ucs', 'nextcloud'];
for (const name of POST_RELEASE_JOBS) {
test(`${name} runs on always(), so a cancellation elsewhere cannot skip it`, () => {
const cond = jobIf(name);
assert.ok(cond, `${name} has no job-level if:, so a cancelled run skips it`);
assert.ok(/always\(\)/.test(cond),
`${name} must be guarded with always() - without it a cancelled run (which is ` +
`what a hand-cancelled build-mac-x64 produces) skips it before it starts. Got: ${cond}`);
});
test(`${name} still requires each job it needs to have SUCCEEDED`, () => {
const cond = jobIf(name);
const needs = jobNeeds(name);
assert.ok(needs.length > 0, `${name} has no needs: to guard`);
for (const dep of needs) {
assert.ok(cond.includes(`needs.${dep}.result == 'success'`),
`${name} uses always(), which also runs when ${dep} FAILED - so it has to say ` +
`needs.${dep}.result == 'success' explicitly. Got: ${cond}`);
}
});
}
test('charts waits on docker, so the chart never points at an image that is not pushed', () => {
assert.deepStrictEqual(jobNeeds('charts'), ['docker'],
'the chart advertises the freshly published multi-arch image; docker is what pushes it');
});
test('ucs and nextcloud still wait on prepare, release and docker', () => {
for (const name of ['ucs', 'nextcloud']) {
assert.deepStrictEqual(jobNeeds(name).sort(), ['docker', 'prepare', 'release'],
`${name} publishes an app that points at the released image`);
}
});
test('ucs and nextcloud stay continue-on-error, so neither can fail the release', () => {
for (const name of ['ucs', 'nextcloud']) {
assert.ok(/^ {4}continue-on-error:\s*true\s*$/m.test(job(name)),
`${name} is best-effort reporting; it must not be able to fail the release`);
}
});
console.log(`\n${passed} passed`);
+173
View File
@@ -0,0 +1,173 @@
'use strict';
// Guard: publishing a chart derives the index from the packages, and refuses a
// package whose filename and contents disagree.
// Run: node tests/releaseChartsIndex.test.cjs
//
// The charts repository keeps the chart SOURCE on `main` and the published
// packages plus index.yaml on `gh-pages`, and its own two scripts move between
// them: main's release.sh commits the source, tars `wekan/` into
// wekan-<version>.0.tgz, checks out gh-pages and drops the package there;
// gh-pages' release2.sh commits and pushes. releases/release-charts.sh drives
// both and owns the index in between - and that middle part is what this pins.
//
// TWO THINGS GO WRONG THERE, and both have already happened.
//
// The index used to be EDITED: the newest entry was copied, a few fields
// substituted, and the result prepended. All four of the index's defects came
// from that - a re-run prepended a second entry for a version instead of
// replacing it (9.36.0 ended with four, each a different digest, so a client
// could pick one matching no file); appVersion was set to the CHART version on
// every entry; fields nobody substituted never changed, so every entry claimed
// mongodb 0.7.2 while the packages were 0.7.6; and a release whose charts job
// did not run left a hole nothing went back for. Deriving the index from the
// packages makes all four impossible instead of fixed.
//
// And release.sh names the package from its ARGUMENT (`tar -cvzf
// wekan-$1.0.tgz wekan`) while the version INSIDE comes from the Chart.yaml that
// release-charts.sh sed-ed a moment earlier. When those drift the repository
// gains a file called one version that declares another: wekan-1.2.7.tgz
// (containing 1.2.6) and wekan-6.96.tgz (containing 6.9.6) were both in the
// repository until they were removed. A Helm index keys on the version INSIDE,
// so neither could ever be installed under the name it was given.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const zlib = require('zlib');
const repoRoot = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(repoRoot, 'releases/release-charts.sh'), 'utf8');
const missing = fs.readFileSync(
path.join(repoRoot, '.github/workflows/release-all-missing.yml'), 'utf8');
const releaseAll = fs.readFileSync(
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
test('the index is rebuilt from the packages, not edited in place', () => {
assert.ok(/reindex-charts\.py" --write --charts-dir/.test(script),
'release-charts.sh must rebuild index.yaml from the packages on gh-pages');
assert.ok(!/Prepended chart/.test(script),
'the hand-rolled "prepend a copy of the previous entry" step must be gone - ' +
'it is where the duplicate entries, the wrong appVersion and the frozen ' +
'dependency versions all came from');
assert.ok(!/set_field\(block/.test(script),
'and so must the field substitution it did');
});
test('the packaged chart is checked against its own filename before it is indexed', () => {
assert.ok(/declares chart version/.test(script),
'release-charts.sh must refuse a package whose Chart.yaml version is not the ' +
'version its filename claims - that is how wekan-1.2.7.tgz and wekan-6.96.tgz ' +
'came to exist');
const check = script.indexOf('declares chart version');
const index = script.indexOf('reindex-charts.py" --write');
assert.ok(check !== -1 && check < index,
'the check has to come BEFORE the index is rebuilt, or the bad package is ' +
'indexed and then reported');
});
test('both release workflows publish the chart', () => {
// release-all.yml has always had a charts job; it is release-all-missing.yml
// that could not finish a release's chart, which is how a release that missed
// its chart stayed missing it forever.
assert.ok(/\n charts:\n/.test(releaseAll), 'release-all.yml must have a charts job');
assert.ok(/\n charts:\n/.test(missing),
'release-all-missing.yml must be able to publish a chart a release never got');
for (const [name, text] of [['release-all', releaseAll], ['release-all-missing', missing]]) {
assert.ok(/release-charts\.sh/.test(text),
`${name}.yml's charts job must publish through releases/release-charts.sh, ` +
'so both take the same path');
}
});
test('release-all-missing no longer claims it never touches the charts', () => {
assert.ok(!/never touches Docker, snap, the website or the charts/.test(missing),
'the header said the charts were out of scope; they are not any more, and a ' +
'comment that contradicts the workflow is worse than none');
assert.ok(/It DOES publish the Helm chart/.test(missing),
'and it should say so, with why');
});
test('a chart that is already published is not republished', () => {
assert.ok(/grep -qxF " version: \$\{V\}\.0"/.test(missing),
'the guard must match the published index exactly: -F because the dots in a ' +
'version are regex wildcards otherwise, -x because a four-space-indented ' +
'version line is an entry\'s own and a deeper one is the mongodb subchart\'s');
assert.ok(/Re-publishing an existing version would re-tar the package/.test(missing),
'and say why it matters - a re-tar changes the digest of a chart people have ' +
'already pulled');
});
test('the missing-release charts job cannot run without a token that can push', () => {
const at = missing.indexOf('\n charts:\n');
const body = missing.slice(at, missing.indexOf('\n done:\n'));
assert.ok(/No WEKAN_REPO_TOKEN/.test(body),
'it pushes to another repository, so a missing token must stop it with a ' +
'named warning rather than failing somewhere inside git');
assert.ok(/skip=true/.test(body), 'and skip, not fail');
});
// ── the filename/contents check, run for real ───────────────────────────────
const hasPyYaml = spawnSync('python3', ['-c', 'import yaml'], { stdio: 'ignore' }).status === 0;
// The same shape release.sh produces: `tar -cvzf wekan-<v>.tgz wekan`, so the
// archive holds wekan/Chart.yaml.
function makePackage(file, declaredVersion) {
const body = Buffer.from(
`apiVersion: v2\nname: wekan\nversion: ${declaredVersion}\nappVersion: "x"\n`, 'utf8');
const header = Buffer.alloc(512);
header.write('wekan/Chart.yaml', 0, 'utf8');
header.write('0000644\0', 100);
header.write('0000000\0', 108);
header.write('0000000\0', 116);
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124);
header.write('00000000000\0', 136);
header.write(' ', 148);
header.write('0', 156);
header.write('ustar\0', 257);
header.write('00', 263);
let sum = 0;
for (const b of header) sum += b;
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
fs.writeFileSync(file, zlib.gzipSync(Buffer.concat([header, body, pad, Buffer.alloc(1024)])));
}
// The check as release-charts.sh runs it, lifted out of the script so the test
// exercises the real code rather than a copy of it.
function readDeclaredVersion(file) {
const py = script.split("inside=\"$(TGZ=\"$TGZ\" python3 - <<'PYEOF'\n")[1].split("\nPYEOF")[0];
return execFileSync('python3', ['-c', py], {
env: { ...process.env, TGZ: file }, encoding: 'utf8',
}).trim();
}
if (!hasPyYaml) {
console.log(' -- python3 PyYAML not available; the package check is not exercised');
} else {
test('the check reads the version out of the package, and it is the one that counts', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-relcharts-'));
try {
const good = path.join(dir, 'wekan-10.81.0.tgz');
makePackage(good, '10.81.0');
assert.strictEqual(readDeclaredVersion(good), '10.81.0',
'a package whose name and contents agree reports that version');
// The real 2023 slip: named 1.2.7, declaring 1.2.6.
const bad = path.join(dir, 'wekan-1.2.7.tgz');
makePackage(bad, '1.2.6');
assert.strictEqual(readDeclaredVersion(bad), '1.2.6',
'and a mis-named one reports what it really is, so the caller can refuse it');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
console.log(`\n${passed} passed`);
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// "Profile pictures were lost" after upgrading a 6.x snap (#6583 follow-up):
//
// "I can see Profile Pics from the user in the Admin/User Pannel if i open a
// User. But on the boards i see only the 'missing Picture' Icon"
// "initials are OK, but there is broken avatar image for those that have image"
//
// Run: node tests/requestUserAuth.test.cjs
//
// Nothing was lost. The avatar FILES migrate correctly, and the migrated
// Meteor-Files record even keeps the original CollectionFS `_id`
// (cfsRecordToMeteorFile), so the old URL still points at the right thing. What
// broke is the request for them.
//
// A 6.x install stores `profile.avatarUrl` as `/cfs/files/avatars/<id>`. That
// route serves the legacy CollectionFS bytes if they are still there, and
// otherwise redirects to `/cdn/storage/avatars/<id>` - which asked who was
// asking like this:
//
// const userId = Meteor.userId();
// if (!userId) { res.writeHead(401); ... }
//
// `Meteor.userId()` reads the current DDP invocation's environment. That exists
// inside a method or a publication and NOT in a WebApp handler, where it does not
// return "nobody" - it THROWS "Meteor.userId can only be invoked in method calls
// or publications". The handler's own try/catch then turned that into a 500, so
// the failure presented as a broken image rather than as broken authentication,
// and no avatar served through that route ever reached anybody.
//
// Initials still worked because they are rendered client-side from the username
// and never fetch anything, which is exactly the reported split.
//
// An HTTP request carries its identity in the request. server/lib/requestUser.js
// resolves it the way universalFileServer.js always has.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const helper = read('server/lib/requestUser.js');
const avatarServer = read('server/routes/avatarServer.js');
const legacy = read('server/routes/legacyAttachments.js');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// Declarations only - these files explain the old call at length.
const code = src => src.split('\n')
.filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l))
.join('\n');
test('no WebApp file route asks Meteor.userId() any more', () => {
for (const [name, src] of [['avatarServer', avatarServer], ['legacyAttachments', legacy]]) {
assert.ok(!/Meteor\.userId\(\)/.test(code(src)),
`${name}: Meteor.userId() THROWS in a WebApp handler - it does not return ` +
`null - and the handler's catch turns that into a 500`);
assert.ok(/getUserIdFromRequest\(req\)/.test(code(src)),
`${name}: the identity has to come from the request`);
assert.ok(/await getUserIdFromRequest\(req\)/.test(code(src)),
`${name}: it is async; an unawaited Promise is truthy and would authorise ` +
`everybody`);
}
});
test('the resolver never throws, whatever it is given', () => {
// The callers wrap everything in try/catch and answer 500, so an exception
// here would look exactly like the bug it replaces.
const body = code(helper);
const at = body.indexOf('export async function getUserIdFromRequest');
const fn = body.slice(at);
assert.ok(/try \{/.test(fn) && /catch \(error\)/.test(fn),
'a caller deciding whether to serve a file wants an answer, not an exception');
assert.ok(/return null;/.test(fn));
});
test('it reads the four places a request can carry a token', () => {
const body = code(helper);
for (const source of ['authorization', 'x-auth-token', 'authToken', 'meteor_login_token']) {
assert.ok(body.includes(source),
`${source} is one of the ways WeKan's own client sends it; missing one ` +
`means avatars work in some contexts and not others, which is the bug`);
}
assert.ok(/Accounts\._hashLoginToken/.test(body),
'the stored token is hashed, so the raw one has to be hashed to match');
});
test('Sandstorm, where there is no login token at all, still resolves', () => {
const body = code(helper);
assert.ok(/x-sandstorm-user-id/.test(body),
'on Sandstorm the platform authorises the request and injects the user id; ' +
'token lookup would always fail there and every image would 403');
const at = body.indexOf('export async function getUserIdFromRequest');
const fn = body.slice(at, at + 600);
assert.ok(fn.indexOf('isSandstormRequest') < fn.indexOf('extractLoginToken'),
'checked before the token path, which cannot succeed on Sandstorm');
});
test('a token too short to be one is not looked up', () => {
assert.ok(/raw\.length < 10/.test(code(helper)),
'a junk value should not become a database query on every image request');
});
// ── the path a migrated 6.x avatar actually takes ───────────────────────────
test('the legacy avatar URL still has somewhere to go', () => {
assert.ok(/\/cfs\/files\/avatars\/:fileName/.test(avatarServer),
"a 6.x profile.avatarUrl is /cfs/files/avatars/<id>; that route is what " +
'makes an unmigrated URL work at all');
assert.ok(/cdn\/storage\/avatars\//.test(avatarServer),
'and it redirects to the current route when the legacy bytes are gone');
});
test('a visitor who is not signed in can still see a public board avatar', () => {
// The client appends ?boardId= to every avatar URL "so public viewers can
// access avatars on public boards" (userAvatar.js), and the route ignored it
// and answered 401, so a public board showed the missing-picture icon to
// everybody who was not logged in.
const body = code(avatarServer);
assert.ok(/avatarIsOnAPublicBoard/.test(body),
'the ?boardId= the client sends has to be honoured somewhere');
const at = body.indexOf('async function avatarIsOnAPublicBoard');
const fn = body.slice(at, body.indexOf('\n}', at));
assert.ok(/parseQuery\(req\)\.boardId/.test(fn), 'read from the query string');
assert.ok(/board\.isPublic\(\)/.test(fn),
'a private board must not open its avatars to anonymous callers');
assert.ok(/board\.hasMember\(avatar\.userId\)/.test(fn),
'without this, naming ANY public board would unlock ANY avatar on the ' +
'instance - a public board publishes its own members, not everybody');
assert.ok(/await avatarIsOnAPublicBoard/.test(body),
'awaited, or the 401 branch is never taken and every avatar is public');
});
test('the legacy redirect keeps the query string', () => {
// Every migrated 6.x avatar URL goes through this redirect, so dropping
// ?boardId= here would 401 exactly the installs this fixes.
const body = code(avatarServer);
const at = body.indexOf('/cdn/storage/avatars/${fileName}');
assert.ok(at !== -1);
const around = body.slice(at - 200, at + 120);
assert.ok(/req\.url \|\| ''\)\.split\('\?'\)\[1\]/.test(around),
'the query has to be carried across the redirect');
assert.ok(/\$\{query \? `\?\$\{query\}` : ''\}/.test(around),
'and appended only when there is one, so a plain URL stays plain');
});
test('the migration keeps the id the old URL names', () => {
// This is why the redirect can work at all: the Meteor-Files record created
// from a CollectionFS filerecord reuses its _id.
const migration = read('releases/migrate-mongodb-to-ferretdb.mjs');
const at = migration.indexOf('function cfsRecordToMeteorFile');
const body = migration.slice(at, at + 400);
assert.ok(/_id:\s*doc\._id/.test(body),
'if the migrated avatar got a fresh id, /cdn/storage/avatars/<old id> would ' +
'find nothing and the pictures really would be gone');
});
console.log(`\n${passed} passed`);
+192
View File
@@ -0,0 +1,192 @@
'use strict';
// GHSA-phm4-4v26-j2vq (WhereBleed) — NoSQL injection (CWE-943) through the
// client-supplied query selector of eight Admin Panel handlers.
// Run: node tests/selectorGuard.test.cjs
//
// The Admin Panel's people, org, team and translation publications and their
// companion count/page methods took a selector from the client and validated only
// its TYPE:
//
// check(query, Match.OneOf(Object, null));
//
// which is not validation at all, because a MongoDB selector is executable data.
// `$where` makes the database run the caller's JavaScript once per document
// scanned, so `{ $where: 'while(true){}' }` pins a database worker for as long as
// the caller likes and is repeatable - denial of service for every tenant on the
// instance, from one narrowly-scoped account. The reporter demonstrated it on
// v10.81 against mongo:7: `{ $where: 'sleep(2000) || true' }` made the `people`
// subscription take 2.03s and return the document, `{ $where: 'false' }` returned
// nothing in 0.00s - the caller controlling, in JavaScript, which documents come
// back.
//
// The defence was NOT missing from the codebase - only from these handlers.
// classifySelector and hasWhere were written for exactly this and were already
// wired into the card-window publication. So the fix is not new logic: it is the
// same helper, moved to /server/lib/selectorGuard so there is ONE copy, and
// called by all nine sites.
//
// This suite pins three things: what the guard classifies as injection, that
// every handler routes its selector through it, and that only ONE copy of the
// check exists - the bug was eight places not doing what one place did.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const { classifySelector } = require(path.join(repoRoot, 'models/lib/injectionDetect.js'));
const { hasWhere } = require(path.join(repoRoot, 'models/lib/mongoSelectorSafety.js'));
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// What the guard does, in the guard's own terms: it refuses when classifySelector
// says injection OR hasWhere says $where. Both halves matter - the second is what
// catches a bare `$where`, which is the payload in the report.
const wouldRefuse = selector =>
classifySelector(selector).injection === true || hasWhere(selector) === true;
// ── the payloads from the advisory ──────────────────────────────────────────
test('the reported payloads are refused', () => {
for (const payload of [
{ $where: 'while(true){}' }, // the denial of service
{ $where: 'sleep(2000) || true' }, // the timing proof
{ $where: 'false' }, // the inclusion oracle's other half
{ $where: 'this.isAdmin === true' },
]) {
assert.strictEqual(wouldRefuse(payload), true,
`${JSON.stringify(payload)} must be refused - it is code for the database to run`);
}
});
test('$where nested inside an ordinary-looking query is refused too', () => {
// The people/org handlers merge the caller's selector into `{ $and: [query,
// restriction] }` rather than stripping operators out of it, so a payload one
// level down is exactly as dangerous as one at the top.
assert.strictEqual(wouldRefuse({ $and: [{ username: 'x' }, { $where: 'true' }] }), true);
assert.strictEqual(wouldRefuse({ $or: [{ $where: 'true' }] }), true);
assert.strictEqual(wouldRefuse({ profile: { $where: 'true' } }), true);
});
test('the other execution operators are refused', () => {
// The advisory notes MongoDB 7 happens to reject $where inside the count
// methods' aggregation pipeline, but $function/$accumulator are legitimate
// aggregation operators there and were never separately tested. The guard must
// not depend on an engine-level accident.
assert.strictEqual(wouldRefuse({ $expr: { $function: { body: 'function(){}', args: [], lang: 'js' } } }), true);
assert.strictEqual(wouldRefuse({ $expr: { $accumulator: { init: 'function(){}', lang: 'js' } } }), true);
});
// ── and what must keep working ──────────────────────────────────────────────
test('everything the Admin Panel really sends is untouched', () => {
// If any of these were refused, an admin would see an empty People, Org, Team
// or Translation table - the fix would be worse than the bug.
for (const ordinary of [
{},
null,
undefined,
{ username: 'joe' },
{ username: { $regex: 'jo', $options: 'i' } },
{ 'profile.fullname': { $regex: 'Smith' } },
{ emails: { $elemMatch: { address: 'a@b.c' } } },
{ $or: [{ username: { $regex: 'x' } }, { 'emails.address': { $regex: 'x' } }] },
{ orgs: { $in: ['id1', 'id2'] } },
{ teamIsActive: true },
{ language: 'fi', text: { $regex: 'board' } },
{ createdAt: { $gte: new Date('2026-01-01') } },
{ $and: [{ a: 1 }, { b: { $ne: 2 } }] },
]) {
assert.strictEqual(wouldRefuse(ordinary), false,
`${JSON.stringify(ordinary)} is an ordinary Admin Panel query and must go through`);
}
});
// ── the guard module ────────────────────────────────────────────────────────
const guard = read('server/lib/selectorGuard.js');
test('the refusal is a selector that matches nothing, not an exception', () => {
assert.ok(/_id: Object\.freeze\(\{ \$in: Object\.freeze\(\[\]\) \}\)/.test(guard),
'MATCH_NOTHING must be { _id: { $in: [] } } - the same refusal cardsWindow.js ' +
'already uses in production, so a refused request returns nothing instead of ' +
'throwing at an admin mid-page');
assert.ok(/Object\.freeze/.test(guard),
'and frozen, so a caller cannot mutate the shared object into one that matches');
});
test('a refusal is recorded, not silently swallowed', () => {
assert.ok(/tripCanary\('injection\.nosql-selector'/.test(guard),
'an execution operator in a selector is an attempt, and the canary is what ' +
'names who sent it and from where');
});
// ── every handler the advisory names ────────────────────────────────────────
const HANDLERS = [
['server/publications/people.js', "safeSelector(query, 'people')", 'peopleScopeSelector(user, safeQuery)'],
['server/publications/org.js', "safeSelector(query, 'org')", 'orgScopeSelector(user, safeQuery)'],
['server/publications/team.js', "safeSelector(query, 'team')", 'getTeams(safeQuery'],
['server/publications/translation.js', "safeSelector(query, 'translation')", 'getTranslations(safeQuery'],
['server/models/users.js', "safeSelector(query || {}, 'getUsersCollectionCount')", null],
['server/models/users.js', "safeSelector(query || {}, 'getPeoplePageIds')", null],
['server/models/org.js', "safeSelector(query || {}, 'getOrgsCollectionCount')", null],
['server/models/team.js', "safeSelector(query || {}, 'getTeamsCollectionCount')", null],
['server/models/translation.js', "safeSelector(query || {}, 'getTranslationsCollectionCount')", null],
];
test('all nine call sites route their selector through the guard', () => {
for (const [file, call, usage] of HANDLERS) {
const src = read(file);
assert.ok(src.includes(call),
`${file}: the advisory names this handler; it must call ${call}`);
assert.ok(/import \{ safeSelector \} from '\/server\/lib\/selectorGuard'/.test(src),
`${file}: must import the shared guard`);
if (usage) {
assert.ok(src.includes(usage),
`${file}: the GUARDED selector has to be the one that reaches the query - ` +
`computing safeQuery and then passing the raw one would fix nothing`);
}
}
});
test('no handler still passes the raw selector to the database', () => {
// The specific mistake to guard against: calling safeSelector and then using
// `query` anyway. Checked on the exact expressions the advisory quotes.
const leftovers = [
['server/publications/people.js', 'peopleScopeSelector(user, query)'],
['server/publications/org.js', 'orgScopeSelector(user, query)'],
['server/publications/team.js', 'getTeams(query,'],
['server/publications/translation.js', 'getTranslations(query,'],
['server/models/team.js', 'getTeams(query || {}'],
['server/models/translation.js', 'getTranslations(query || {}'],
['server/models/org.js', 'orgScopeSelector(user, query || {})'],
['server/models/users.js', 'peopleScopeSelector(currentUser, query || {})'],
];
for (const [file, raw] of leftovers) {
assert.ok(!read(file).includes(raw),
`${file}: still passes the unguarded selector (${raw})`);
}
});
test('there is exactly ONE copy of the check', () => {
// The bug was eight places not doing what one place did. A second copy is that
// set up to happen again, so cardsWindow.js imports the shared one rather than
// keeping its own.
const cards = read('server/publications/cardsWindow.js');
assert.ok(/import \{ selectorIsInjection \} from '\/server\/lib\/selectorGuard'/.test(cards),
'cardsWindow.js must import the guard it used to define');
assert.ok(!/function selectorIsInjection/.test(cards),
'and must not define its own any more');
assert.ok(/function selectorIsInjection/.test(guard),
'the one definition lives in the shared module');
assert.ok(cards.includes("selectorIsInjection(cardSelector, 'boardCardsWindow')") &&
cards.includes("selectorIsInjection(cardSelector, 'boardCardsCount')"),
'and its two existing refusal sites are unchanged');
});
console.log(`\n${passed} passed`);
+16 -2
View File
@@ -81,13 +81,21 @@ test('it changes nothing and stops cleanly, rather than failing into a restart',
test('mongodb-control does not start a mongod that is known to fail', () => {
const at = mongodb.indexOf(MARKER);
assert.notStrictEqual(at, -1, 'mongodb-control reads the marker');
const block = mongodb.slice(at, at + 1400);
// Window widened for #6471: the refusal is now preceded by the staleness check
// that lets a NEW revision retry, so the block being asserted on starts further
// up. The behaviour asserted below is unchanged.
const block = mongodb.slice(at, at + 2600);
assert.ok(/exit 0/.test(block), 'and exits 0 rather than looping through snapd restarts');
// The marker check must come BEFORE mongod is started, or the loop is unchanged.
assert.ok(mongodb.indexOf(MARKER) < mongodb.indexOf('handle_mongod_start_failure'),
'the check is before the start-failure handling it replaces');
assert.ok(/snap revert/.test(block) && /mongodump|dump the data/.test(block),
'and the log says both ways forward');
// #6471: ...but only when the marker is about THIS revision. A marker written
// by an older snap, which had fewer readers, must not stop this one trying -
// that is what made 10.81 behave exactly like 10.79 for the reporter.
assert.ok(/stale-marker" "\$MONGODB_DATA_TOO_OLD"/.test(block),
'the refusal must first ask whether the marker is even about this snap');
});
test('wekan-control serves the page instead of waiting for a database that is not coming', () => {
@@ -190,7 +198,13 @@ test('mongod 4.2 is bundled for amd64 and arm64, and only used to READ', () => {
// It must RUN in the build, not merely exist.
assert.ok(/LD_LIBRARY_PATH="\$dest\/lib" "\$dest\/bin\/mongod" --version/.test(part),
'the build runs the staged binary once, so a missing library is found here');
assert.ok(/does not run in this environment[\s\S]{0,200}rm -rf "\$dest"/.test(part),
// CHANGED DELIBERATELY: it used to require `rm -rf "$dest"`, and removing the
// DIRECTORY is what ended the whole snap build - `stage: mongo42` names that
// path, and snapcraft fails on a filter whose path is missing rather than
// skipping it. The give-up still unstages the binary; it just leaves the empty
// directory behind for the filter to copy. migration-control guards every use
// on `-x "$M42/bin/mongod"`, so an empty one still means "no 4.2 reader".
assert.ok(/does not run in this environment[\s\S]{0,300}rm -rf "\$\{dest:\?\}"\/\*/.test(part),
'and unstages it rather than shipping something that cannot start');
// Optional by design: a failure must not fail the snap.
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Guard: a part that names paths in `stage:` / `prime:` must produce them on
// EVERY architecture it is built for.
// Run: node tests/snapPartFilters.test.cjs
//
// This is the v10.80 snap failure, and it took four releases to read correctly
// because the job's own error message guessed at it. All four Launchpad
// architectures - armhf, s390x, ppc64el, riscv64, three attempts each - died on
// one deterministic line in the build log:
//
// Staging mongo42
// Failed to copy '/build/.../parts/mongo42/install/mongo42':
// no such file or directory.
// Build failed
//
// `mongo42` downloads MongoDB 4.2 so an old database can be migrated, and
// MongoDB publishes 4.2 for amd64 and arm64 only - so on every other
// architecture the part printed "nothing to migrate from there; skipping" and
// exited 0 having created nothing. Its comment calls it "OPTIONAL BY DESIGN".
// It was not optional: `stage: [mongo42]` and `prime: [mongo42]` name that path
// explicitly, and snapcraft does not skip a filter whose path is missing - it
// ends the build. So an optional migration helper took the whole snap down on
// the four architectures that have NO native runner and can only be built there.
//
// The part creates its directory before anything can decide to skip now, so the
// filters always have something to copy and "no MongoDB 4.2 here" stays a
// missing BINARY rather than a failed build.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const yaml = (() => {
try { return require('js-yaml'); } catch { return null; }
})();
const repoRoot = path.resolve(__dirname, '..');
const raw = fs.readFileSync(path.join(repoRoot, 'snapcraft.yaml'), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// The parts, read without a YAML library so this suite needs no dependency:
// a part is a key at four spaces under `parts:`, and its body runs to the next.
function parts() {
const at = raw.indexOf('\nparts:\n');
assert.notStrictEqual(at, -1, 'snapcraft.yaml has no parts:');
const body = raw.slice(at + 1);
const out = {};
const re = /^ {4}([a-z0-9][a-z0-9._-]*):$/gm;
const starts = [];
let m;
while ((m = re.exec(body)) !== null) starts.push({ name: m[1], at: m.index });
for (let i = 0; i < starts.length; i += 1) {
const end = i + 1 < starts.length ? starts[i + 1].at : body.length;
out[starts[i].name] = body.slice(starts[i].at, end);
}
return out;
}
const allParts = parts();
test('the parts of snapcraft.yaml are found', () => {
assert.ok(Object.keys(allParts).length >= 3,
`expected several parts, found ${Object.keys(allParts).join(', ') || 'none'}`);
for (const name of ['mongo42', 'wekan']) {
assert.ok(allParts[name], `snapcraft.yaml has no ${name} part`);
}
});
test('snapcraft.yaml is valid YAML and its filters read the same as the text', () => {
if (!yaml) return; // js-yaml is optional here; the text scan above is the guard
const doc = yaml.load(raw);
assert.deepStrictEqual(doc.parts.mongo42.stage, ['mongo42']);
assert.deepStrictEqual(doc.parts.mongo42.prime, ['mongo42']);
});
test('mongo42 creates its staged directory BEFORE it can decide to skip', () => {
// Comment lines dropped: the part now EXPLAINS the `exit 0` at length above the
// code, and prose about a line must not be mistaken for the line.
const part = allParts.mongo42
.split('\n').filter(l => !/^\s*#/.test(l)).join('\n');
const mkdir = part.indexOf('mkdir -p "$dest/bin" "$dest/lib"');
const skip = part.indexOf('exit 0');
assert.notStrictEqual(mkdir, -1, 'mongo42 must create $SNAPCRAFT_PART_INSTALL/mongo42');
assert.notStrictEqual(skip, -1, 'mongo42 must still skip on an unsupported architecture');
assert.ok(mkdir < skip,
'the directory has to exist before the unsupported-architecture `exit 0`, or ' +
'`stage: [mongo42]` has nothing to copy and snapcraft ENDS THE BUILD - which ' +
'is what failed every Launchpad architecture in v10.80');
const dest = part.indexOf('dest="$SNAPCRAFT_PART_INSTALL/mongo42"');
assert.ok(dest !== -1 && dest < skip, '$dest must be set before the skip too');
});
test('no path in mongo42 deletes the directory its stage filter names', () => {
// The first fix covered the unsupported-architecture `exit 0`. It did NOT cover
// the other two ways this part gives up - OpenSSL 1.1 unavailable, and the
// staged mongod failing its smoke test - which both did `rm -rf "$dest"` and
// exited 0, leaving `stage: mongo42` naming a path that is not there. On
// amd64/arm64, where the binary IS downloaded, either of those would have ended
// the whole snap build exactly as the unsupported arches did.
const part = allParts.mongo42;
assert.ok(!/rm -rf "\$dest"\s*$/m.test(part) && !/rm -rf "\$\{dest\}"\s*$/m.test(part),
'a give-up path must remove the CONTENTS and keep the directory, so the ' +
'filter has something to copy: rm -rf "${dest:?}"/*');
const gives_up = (part.match(/rm -rf "\$\{dest:\?\}"\/\*/g) || []).length;
assert.ok(gives_up >= 2,
`both give-up paths (no OpenSSL 1.1, smoke test failed) must clear contents ` +
`only; found ${gives_up}`);
});
test('mongo42 is still amd64/arm64 only - the fix is not "download it everywhere"', () => {
const part = allParts.mongo42;
assert.ok(/amd64\) MARCH=x86_64/.test(part) && /arm64\) MARCH=aarch64/.test(part),
'the two architectures MongoDB publishes 4.2 for must stay the only two that ' +
'download it');
assert.ok(/nothing to migrate from there; skipping/.test(part),
'and every other architecture must still skip, with a reason in the log');
});
test('every path a part stages or primes is one that part can produce', () => {
// The general rule the mongo42 failure is one instance of. A filter entry that
// is a literal path (no glob) must appear in the part's override-build as
// something it creates - otherwise there is an architecture where it will not
// be there, and the build ends rather than skipping.
const problems = [];
for (const [name, body] of Object.entries(allParts)) {
for (const key of ['stage', 'prime']) {
const m = body.match(new RegExp(`^ {8}${key}:\\n((?: {12}- .+\\n)+)`, 'm'));
if (!m) continue;
for (const line of m[1].trim().split('\n')) {
const entry = line.replace(/^\s*-\s*/, '').trim();
if (entry.startsWith('-') || entry.includes('*')) continue; // exclusions/globs
const build = body.match(/override-build: \|\n([\s\S]*?)(?=\n {8}[a-z-]+:|\n {4}[a-z0-9-]+:|$)/);
if (!build) continue;
if (!build[1].includes(entry)) {
problems.push(`${name}: ${key} names "${entry}", which its override-build never creates`);
}
}
}
}
assert.deepStrictEqual(problems, [],
'a stage:/prime: entry that the part does not always produce is not skipped - ' +
'snapcraft fails the whole build on it:\n ' + problems.join('\n '));
});
test('the wekan part retries npm install, which is what the build farm drops', () => {
const part = allParts.wekan;
assert.ok(/for attempt in 1 2 3; do/.test(part),
'npm install must be retried: on Launchpad every request goes through the ' +
'build farm proxy and a single tarball can be cut mid-stream (ECONNRESET), ' +
'which ended the riscv64 snap in v10.80 after half an hour of building');
assert.ok(/npm install failed three times in a row/.test(part),
'and after three tries it must fail loudly rather than retry forever');
});
test('the job error message points at the log instead of guessing', () => {
const workflow = fs.readFileSync(
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
// ALL of them: the job prints more than one - the retry loop's diagnosis and
// the "Job result" step's one-liner - and picking the first found the wrong one.
const lines = workflow.split('\n').filter(l => l.includes('::error::snap-launchpad'));
assert.ok(lines.length, 'the snap-launchpad failure must print a named ::error::');
assert.ok(lines.some(l => /the Launchpad build log is printed above - read it first/.test(l)),
'the log is printed by the step above and it names the failing part; the ' +
'message must send the reader there. Got:\n ' + lines.join('\n '));
assert.ok(!lines.some(l => /often an OOM in the Meteor npm install/.test(l)),
'that guess was wrong for four releases while the log said "Failed to copy ' +
'.../parts/mongo42/install/mongo42" every time');
});
console.log(`\n${passed} passed`);
+173
View File
@@ -0,0 +1,173 @@
'use strict';
// #6471 — "Snap 10.79 This version of MongoDB is too recent to start up on the
// existing data files. Try MongoDB 4.2 or earlier.", reopened against 10.81:
//
// "on 10.81 I still have the exact same issue, MongoDB cannot start. The web
// interface is still unreachable, and I do not see the messages you added in
// the last commits."
//
// Run: node tests/snapStaleMarker.test.cjs
//
// The 4.2 reader added for this issue was present in their snap and never ran,
// because TWO MARKERS IN $SNAP_COMMON WERE PERMANENT:
//
// .mongodb-data-too-old written when no reader in the snap could open the
// data. mongodb-control then refuses to start mongod
// at all, and wekan-control serves an explanatory page
// instead of WeKan.
// .mongod-start-failures a counter; past three, mongodb-control stops
// re-running the migration so a migration<->mongod
// restart loop cannot form.
//
// Both are right and both record a conclusion about what THAT SNAP could do. An
// instance that failed on 10.79 or 10.80 - before the 4.2 reader existed - kept a
// marker saying "no reader can open this" and a counter far past three, so 10.81
// never started mongod, never ran the migration, and printed none of the new
// messages. Upgrading to the version with the fix changed nothing, which is
// exactly what their log shows: the database selection line, then "Waiting for
// MongoDB replica set primary..." forever.
//
// A NEW REVISION IS A NEW CHANCE. Each marker records the revision that wrote it;
// when the running revision differs it is stale and the snap tries again. Within
// one revision nothing changes - the loop protection and the refusal both still
// hold - so this does not reintroduce the loop they exist to prevent.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..');
const helper = path.join(repoRoot, 'snap-src/bin/stale-marker');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const mongodbControl = read('snap-src/bin/mongodb-control');
const wekanControl = read('snap-src/bin/wekan-control');
const migrationControl = read('snap-src/bin/migration-control');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
const run = (args, revision) =>
spawnSync('bash', [helper, ...args], {
env: { ...process.env, SNAP_REVISION: revision === undefined ? '' : String(revision) },
}).status;
const isStale = (marker, revision) => run([marker], revision) === 0;
const stamp = (marker, revision) => run(['--stamp', marker], revision);
function withMarker(fn) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-marker-'));
const marker = path.join(dir, '.mongodb-data-too-old');
try { return fn(marker, dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
test('the helper exists, is executable and parses', () => {
assert.ok(fs.existsSync(helper), 'snap-src/bin/stale-marker is missing');
assert.ok(fs.statSync(helper).mode & 0o111, 'it must be executable');
assert.strictEqual(spawnSync('bash', ['-n', helper]).status, 0);
});
test('the reported case: a marker written by an older revision is stale', () => {
withMarker(marker => {
fs.writeFileSync(marker, '4.2\n');
stamp(marker, 3449); // the revision that gave up
assert.strictEqual(isStale(marker, 3500), true,
'a newer revision may have readers the old one did not - which is the whole ' +
'of this issue');
});
});
test('within the SAME revision the marker still holds', () => {
withMarker(marker => {
fs.writeFileSync(marker, '4.2\n');
stamp(marker, 3500);
assert.strictEqual(isStale(marker, 3500), false,
'or the loop protection and the "do not start mongod on data it cannot ' +
'read" refusal would both be gone');
});
});
test('a marker from before this mechanism is stale by definition', () => {
withMarker(marker => {
fs.writeFileSync(marker, '4.2\n'); // no companion revision file
assert.strictEqual(isStale(marker, 3500), true,
'that is precisely the marker the affected instances are carrying - written ' +
'by a snap that did not record revisions');
});
});
test('not knowing the revision is never treated as evidence it changed', () => {
withMarker(marker => {
fs.writeFileSync(marker, '4.2\n');
stamp(marker, 3449);
assert.strictEqual(isStale(marker, undefined), false,
'outside a snap there is no SNAP_REVISION; the marker must be honoured ' +
'rather than cleared on a guess');
});
});
test('no marker is not stale, and stamping without a revision does nothing', () => {
withMarker((marker, dir) => {
assert.strictEqual(isStale(marker, 3500), false, 'nothing to be stale about');
stamp(marker, undefined);
assert.strictEqual(fs.existsSync(`${marker}.revision`), false,
'a revision file with no revision in it would make every marker look current');
assert.ok(fs.existsSync(dir));
});
});
// ── the three places that consult it ────────────────────────────────────────
test('mongodb-control retries when the "too old" marker is from another revision', () => {
assert.ok(/stale-marker" "\$MONGODB_DATA_TOO_OLD"/.test(mongodbControl),
'the refusal to start mongod must ask whether the marker is even about this snap');
const at = mongodbControl.indexOf('stale-marker" "$MONGODB_DATA_TOO_OLD"');
const after = mongodbControl.slice(at, at + 400);
assert.ok(/rm -f "\$MONGODB_DATA_TOO_OLD"/.test(after),
'and clear it, so the run proceeds normally rather than being skipped again');
assert.ok(/mongod-start-failures/.test(after),
'and clear the failure counter with it - an instance that gave up on an older ' +
'revision has both, and leaving one of them latched fixes nothing');
});
test('the failure counter is per revision, not per installation', () => {
const at = mongodbControl.indexOf('local fails=0');
const body = mongodbControl.slice(at, at + 700);
assert.ok(/! bash "\$SNAP\/bin\/stale-marker" "\$MONGOD_FAIL_COUNT_FILE"/.test(body),
'a count from another revision must not be carried forward');
assert.ok(/stale-marker" --stamp "\$MONGOD_FAIL_COUNT_FILE"/.test(body),
'and the count that is written has to record which revision it belongs to');
});
test('a mongod that DOES start still clears the counter completely', () => {
assert.ok(/rm -f "\$MONGOD_FAIL_COUNT_FILE" "\$\{MONGOD_FAIL_COUNT_FILE\}\.revision"/.test(mongodbControl),
'success clears both files, or the next failure would inherit a revision stamp ' +
'with no counter');
});
test('the marker records the revision that wrote it', () => {
assert.ok(/stale-marker" --stamp "\$SNAP_COMMON\/\.mongodb-data-too-old"/.test(migrationControl),
'migration-control writes the marker, so it is what must stamp it');
const write = migrationControl.indexOf("printf '%s\\n' \"$can\" >");
const stampAt = migrationControl.indexOf('--stamp "$SNAP_COMMON/.mongodb-data-too-old"');
assert.ok(write !== -1 && stampAt > write, 'stamped after it is written');
});
test('wekan-control does not keep serving the page on a stale marker', () => {
assert.ok(/stale-marker" "\$SNAP_COMMON\/\.mongodb-data-too-old"/.test(wekanControl),
'this service can start before mongodb-control, so it has to make the same ' +
'judgement rather than serving "too old for this snap" on a snap that has ' +
'not tried yet');
const at = wekanControl.indexOf('stale-marker" "$SNAP_COMMON/.mongodb-data-too-old"');
const page = wekanControl.indexOf('DATABASE TOO OLD FOR THIS SNAP');
assert.ok(at !== -1 && at < page, 'and check before it decides to serve it');
});
test('the helper ships in the snap', () => {
const snapcraft = read('snapcraft.yaml');
assert.ok(/helpers:\s*\n\s*source: snap-src\s*\n\s*plugin: dump/.test(snapcraft),
'snap-src is dumped wholesale, which is what puts bin/stale-marker in $SNAP/bin');
});
console.log(`\n${passed} passed`);
+16 -5
View File
@@ -104,18 +104,29 @@ test('the people publication and its count use the SAME scope function', () => {
const count = read('server/models/users.js');
assert.ok(/tenantAdmin\.canOpenAdminPanel\(user\)/.test(pub),
'the publication opens for a per-tenant admin too');
assert.ok(/tenantAdmin\.peopleScopeSelector\(user, query\)/.test(pub),
'and scopes with the shared rule instead of the raw query');
// GHSA-phm4-4v26-j2vq: what reaches the scope function is the selector AFTER the
// injection guard, not the raw one. The scoping rule is unchanged - it still
// merges the restriction under $and - but it no longer has to be handed a
// selector that might carry $where, because merging never stripped that out.
assert.ok(/tenantAdmin\.peopleScopeSelector\(user, safeQuery\)/.test(pub),
'and scopes with the shared rule, applied to the guarded query');
assert.ok(/safeSelector\(query, 'people'\)/.test(pub),
'which means the guard has to run first');
assert.ok(!/user\.isAdmin/.test(live(pub)), 'no second opinion left in the publication');
assert.ok(/tenantAdmin\.peopleScopeSelector\(currentUser, query \|\| \{\}\)/.test(count),
// Same rule, same guard: the count and the page ids both scope the selector the
// publication scopes, and both hand it over only after the injection guard
// (GHSA-phm4-4v26-j2vq) has had it.
assert.ok(/tenantAdmin\.peopleScopeSelector\(currentUser, safeSelector\(query \|\| \{\}, 'getUsersCollectionCount'\)\)/.test(count),
'getUsersCollectionCount is scoped the same way, or the pager counts the wrong set');
assert.ok(/tenantAdmin\.peopleScopeSelector\(currentUser, safeSelector\(query \|\| \{\}, 'getPeoplePageIds'\)\)/.test(count),
'and so is getPeoplePageIds, which reads the page back');
});
test('the org publication and its count are scoped the same way', () => {
const pub = read('server/publications/org.js');
const count = read('server/models/org.js');
assert.ok(/tenantAdmin\.orgScopeSelector\(user, query\)/.test(pub));
assert.ok(/tenantAdmin\.orgScopeSelector\(user, query \|\| \{\}\)/.test(count));
assert.ok(/tenantAdmin\.orgScopeSelector\(user, safeQuery\)/.test(pub));
assert.ok(/tenantAdmin\.orgScopeSelector\(user, safeSelector\(query \|\| \{\}, 'getOrgsCollectionCount'\)\)/.test(count));
assert.ok(/tenantAdmin\.canOpenAdminPanel\(user\)/.test(count));
});
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Email feedback (../log/wekan17/email-feedback/cpu-monster): FerretDB at
// 190-350% CPU on an instance with 14490 boards, of which 13404 are template
// containers, for 9264 accounts of which 478 have ever logged in.
// Run: node tests/unusedTemplateContainers.test.cjs
//
// Before v10.00 every new account got a "Templates" container board at signup
// whether or not the person ever saved a template. That was made lazy in v10.00
// (#2339, #5850), so no NEW account creates one - but nothing ever removed the
// ones already made, and they are not visible enough for anyone to delete by
// hand. They are 13x the boards collection on that instance, and every query
// that touches boards carries it.
//
// THIS DELETES BOARDS, so the rule for what may go is the whole of the risk and
// is deliberately narrow: only a container nobody ever used. Anything at all
// having been done to one - a template saved into it, a list or card added, a
// second member, a rename, a star, a manual archive - keeps it. This suite is
// about the cases that must NOT be removed at least as much as the one that
// should.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const {
classifyTemplateContainer,
templateContainersSelector,
planTemplateContainerCleanup,
} = require(path.join(repoRoot, 'models/lib/unusedTemplateContainers.js'));
const method = fs.readFileSync(
path.join(repoRoot, 'server/methods/cleanupTemplateContainers.js'), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
const container = (over = {}) => ({
_id: 'b1',
type: 'template-container',
title: 'Templates',
members: [{ userId: 'u1', isAdmin: true }],
archived: false,
starred: false,
...over,
});
const EMPTY = { templateCount: 0, listCount: 0, swimlaneCount: 0, cardCount: 0 };
const OPTS = { defaultTitles: ['Templates', 'templates', 'Template Container'] };
test('an untouched, empty container is removable', () => {
const v = classifyTemplateContainer(container(), EMPTY, OPTS);
assert.strictEqual(v.removable, true, JSON.stringify(v.reasons));
assert.deepStrictEqual(v.reasons, []);
});
test('anything saved into it keeps it', () => {
for (const [field, label] of [
['templateCount', 'template'],
['cardCount', 'card'],
['listCount', 'list'],
['swimlaneCount', 'swimlane'],
]) {
const v = classifyTemplateContainer(
container(), { ...EMPTY, [field]: 1 }, OPTS);
assert.strictEqual(v.removable, false,
`a container holding a ${label} was used, so it is not ours to delete`);
assert.ok(v.reasons.some(r => r.includes(label)),
`and the reason has to say so: ${JSON.stringify(v.reasons)}`);
}
});
test('a shared, renamed, starred or archived container keeps it', () => {
const cases = [
[container({ members: [{ userId: 'u1' }, { userId: 'u2' }] }), 'shared'],
[container({ title: 'My project templates' }), 'renamed'],
[container({ starred: true }), 'starred'],
[container({ archived: true }), 'archived'],
];
for (const [board, why] of cases) {
const v = classifyTemplateContainer(board, EMPTY, OPTS);
assert.strictEqual(v.removable, false,
`a ${why} container is somebody's, whatever is inside it`);
assert.ok(v.reasons.length > 0);
}
});
test('a rename is only a rename against the titles the app really used', () => {
// The default title is translated and has changed, so the caller supplies the
// set. Without it, nothing is judged on the title at all - better than
// deleting a board because its default name is in another language.
const german = container({ title: 'Vorlagen' });
assert.strictEqual(classifyTemplateContainer(german, EMPTY, OPTS).removable, false,
'an unknown title counts as renamed when a list is given');
assert.strictEqual(classifyTemplateContainer(german, EMPTY, {}).removable, true,
'and is not judged at all when no list is given');
assert.strictEqual(
classifyTemplateContainer(container({ title: ' Templates ' }), EMPTY, OPTS).removable,
true, 'whitespace is not a rename');
});
test('nothing that is not a template container is ever touched', () => {
for (const type of ['board', 'template-board', 'template-list', undefined]) {
const v = classifyTemplateContainer(container({ type }), EMPTY, OPTS);
assert.strictEqual(v.removable, false, `type ${type} must be left alone`);
}
for (const junk of [null, undefined, 'nope', 42]) {
assert.strictEqual(classifyTemplateContainer(junk, EMPTY, OPTS).removable, false);
}
});
test('the selector narrows by type only, so the counts decide', () => {
assert.deepStrictEqual(templateContainersSelector(), { type: 'template-container' },
'what makes one removable needs counts from four other collections, so the ' +
'selector cannot express it and must not pretend to');
});
test('the plan separates what would go from what stays, with reasons', () => {
const plan = planTemplateContainerCleanup([
{ board: container({ _id: 'empty' }), counts: EMPTY, options: OPTS },
{ board: container({ _id: 'used' }), counts: { ...EMPTY, templateCount: 3 }, options: OPTS },
{ board: container({ _id: 'mine', title: 'Renamed' }), counts: EMPTY, options: OPTS },
]);
assert.deepStrictEqual(plan.remove.map(r => r.boardId), ['empty']);
assert.deepStrictEqual(plan.keep.map(r => r.boardId).sort(), ['mine', 'used']);
assert.ok(plan.keep.every(k => k.reasons.length > 0),
'every kept board must say why it was kept - that is what tells an admin the ' +
'rule is doing what they think');
});
// ── the method around it ────────────────────────────────────────────────────
test('it does nothing unless asked twice', () => {
assert.ok(/const apply = options\.apply === true;/.test(method),
'the default must be a dry run: for thirteen thousand boards that is the ' +
'difference between a cleanup and an accident');
const dry = method.indexOf('if (!apply)');
const remove = method.indexOf('Boards.removeAsync');
assert.ok(dry !== -1 && remove > dry, 'the dry run returns before anything is removed');
assert.ok(/wouldRemove: plan\.remove\.length/.test(method),
'and reports what WOULD go');
assert.ok(/removeSample|keepSample/.test(method),
'with a sample rather than thirteen thousand rows');
});
test('only an admin can run it', () => {
assert.ok(/\?\.isAdmin\) \{[\s\S]{0,120}throw new Meteor\.Error\('not-authorized'/.test(method),
'it deletes boards across every account');
const guard = method.indexOf("not-authorized");
// the CALL, not the definition further up the file
const scan = method.indexOf('await collectContainers(limit)');
assert.ok(guard !== -1 && guard < scan,
'and the check must come before it even reads the boards');
});
test('the user pointer is cleared with the board', () => {
assert.ok(/profile\.templatesBoardId/.test(method) && /\$unset/.test(method),
'a profile still pointing at a deleted board is what ensureTemplatesBoard ' +
'would trip over next');
});
test('a failure on one board does not abandon the rest', () => {
assert.ok(/failed\.push\(/.test(method) && /catch \(error\)/.test(method),
'thirteen thousand deletions must not stop at the first problem, and the ' +
'ones that failed have to be reported');
});
test('the method is registered, or it does not exist', () => {
const imports = fs.readFileSync(path.join(repoRoot, 'server/imports.js'), 'utf8');
assert.ok(/import '\/server\/methods\/cleanupTemplateContainers';/.test(imports));
});
console.log(`\n${passed} passed`);
+131
View File
@@ -0,0 +1,131 @@
'use strict';
// Guard: every runner label any workflow asks for is one GitHub still offers.
// Run: node tests/workflowRunnerLabels.test.cjs
//
// A retired label does not fail the job - that is the whole problem. The job
// QUEUES, waiting for a runner that is never coming, and sits there until
// somebody cancels it by hand. In the v10.80 release run, build-mac-x64 asked
// for `macos-13`, which GitHub had retired; it queued for nearly two hours, was
// cancelled, and cancelling it cancelled the RUN - which SKIPPED the charts, ucs
// and nextcloud jobs, because a cancelled run skips every job that has not
// started yet. The chart for a released, published WeKan was never pushed, and
// nothing in the log said "macos-13 no longer exists". No release has ever
// carried a wekan-<version>-mac-x64.zip.
//
// So the label is checked here, where it costs a second, instead of being
// discovered as a two-hour queue during a release. Intel macOS did not go away -
// it was RENAMED: macos-15-intel and macos-26-intel. macos-14/15/26 without the
// suffix are Apple Silicon.
//
// This list is GitHub's hosted-runner set (docs.github.com "GitHub-hosted
// runners"). When GitHub adds an image, add the label here too - a label this
// file does not know is reported, because an unknown label and a retired one
// look exactly the same from the outside, and both queue forever.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const workflowDir = path.join(repoRoot, '.github/workflows');
// Currently offered GitHub-hosted labels.
const CURRENT = new Set([
// Ubuntu x64 / arm64
'ubuntu-latest', 'ubuntu-26.04', 'ubuntu-24.04', 'ubuntu-22.04', 'ubuntu-slim',
'ubuntu-26.04-arm', 'ubuntu-24.04-arm', 'ubuntu-22.04-arm',
// Windows x64 / arm64
'windows-latest', 'windows-2025', 'windows-2025-vs2026', 'windows-2022',
'windows-11-arm', 'windows-11-vs2026-arm',
// macOS arm64, then macOS Intel
'macos-latest', 'macos-26', 'macos-15', 'macos-14',
'macos-26-intel', 'macos-15-intel',
]);
// Labels GitHub has REMOVED. Kept by name so the failure can say what replaced
// them rather than only "unknown label".
const RETIRED = new Map([
['macos-15-large', 'a larger-runner label, not available here'],
['macos-13', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
['macos-13-xl', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
['macos-12', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
['macos-11', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
['ubuntu-20.04', 'retired - use ubuntu-22.04 or newer'],
['ubuntu-18.04', 'retired - use ubuntu-22.04 or newer'],
['windows-2019', 'retired - use windows-2022 or newer'],
['windows-2016', 'retired - use windows-2022 or newer'],
]);
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// Every `runs-on:` in every workflow, and every matrix value one can expand to.
// A `runs-on: ${{ matrix.foo }}` is followed to the `foo:` entries of the matrix,
// which is where the real labels are - checking only the literal `runs-on:`
// lines would miss the arm64 and Intel-Mac legs entirely.
function collectLabels() {
const found = [];
for (const file of fs.readdirSync(workflowDir).filter(f => /\.ya?ml$/.test(f))) {
const text = fs.readFileSync(path.join(workflowDir, file), 'utf8');
text.split('\n').forEach((line, i) => {
const where = `${file}:${i + 1}`;
const direct = line.match(/^\s*runs-on:\s*(.+?)\s*$/);
if (direct) {
// Strip a trailing comment, and skip the expression forms - their real
// values come from the matrix keys matched below.
const value = direct[1].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '');
if (!value.includes('${{') && value !== '' && !value.startsWith('[')) {
found.push({ where, label: value });
}
return;
}
// Matrix entries that feed a runs-on, e.g. `runs_on: ubuntu-24.04-arm`
// or `- runs_on: macos-15-intel`.
const matrix = line.match(/^\s*-?\s*(?:runs_on|runner|runs-on-label):\s*(.+?)\s*$/);
if (matrix) {
const value = matrix[1].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '');
if (!value.includes('${{') && value !== '') found.push({ where, label: value });
}
});
}
return found;
}
const labels = collectLabels();
test('workflows declare at least one runner label (the scan is not silently empty)', () => {
assert.ok(labels.length >= 10,
`expected to find runner labels across .github/workflows, found ${labels.length}`);
});
test('no workflow asks for a runner label GitHub has retired', () => {
const dead = labels.filter(l => RETIRED.has(l.label));
assert.deepStrictEqual(dead, [],
'these runner labels are retired and will QUEUE FOREVER instead of failing:\n' +
dead.map(d => ` ${d.where}: ${d.label} - ${RETIRED.get(d.label)}`).join('\n'));
});
test('every runner label is one GitHub currently offers', () => {
const unknown = labels.filter(l => !CURRENT.has(l.label) && !RETIRED.has(l.label));
assert.deepStrictEqual(unknown, [],
'unknown runner label(s) - either GitHub added an image and CURRENT needs it, ' +
'or this is a typo that will queue forever:\n' +
unknown.map(u => ` ${u.where}: ${u.label}`).join('\n'));
});
test('build-mac-x64 asks for an Intel macOS runner, not an Apple Silicon one', () => {
const workflow = fs.readFileSync(path.join(workflowDir, 'release-all.yml'), 'utf8');
const start = workflow.indexOf('\n build-mac-x64:\n');
assert.notStrictEqual(start, -1, 'release-all.yml has no build-mac-x64 job');
const rest = workflow.slice(start + 1);
const next = rest.search(/\n [a-z0-9-]+:\n/);
const body = next === -1 ? rest : rest.slice(0, next);
const m = body.match(/^\s*runs-on:\s*(\S+)/m);
assert.ok(m, 'build-mac-x64 has no runs-on');
assert.ok(/-intel$/.test(m[1]),
`build-mac-x64 builds the x86_64 macOS bundle, so it needs an Intel runner ` +
`(macos-15-intel / macos-26-intel); it asks for "${m[1]}", which is Apple Silicon`);
});
console.log(`\n${passed} passed`);