mirror of
https://github.com/wekan/wekan.git
synced 2026-11-04 17:10:08 -05:00
Add semi-open boards, visible to every signed-in user.
A board's visibility can now be "Signed-in users" (permission 'instance'): every signed-in user of this WeKan reads it, nobody signed out does, and only its members edit it, as on a public board. One pure rule, models/lib/boardPermission.js, feeds isVisibleBy and the shared selector builder, so the board and card publications, All Boards, search, exports, attachments, backgrounds, watching, filter presets and rule destinations follow it. Anonymous paths - link previews, public avatars, the no-token export branch and archived lists - stay public-only, and a source suite lists every remaining public-only check so a new one is reviewed. The Public page and GET /api/boards list instance boards for a signed-in caller only; "private boards only" refuses them like public ones; a WeKan export keeps the setting on import. New strings are English, pending Transifex. Fixes #3249, Thanks to relikd and xet7 !
This commit is contained in:
1 parent
56cc45a6e0
commit
f122a8287c
34 files changed
+424
-68
No files matched your search
@@ -815,7 +815,8 @@ Template.boardBody.helpers({
|
||||
allowPrivateVisibilityOnly !== undefined &&
|
||||
allowPrivateVisibilityOnly.booleanValue &&
|
||||
currentBoard &&
|
||||
currentBoard.permission == 'public'
|
||||
// #3249: an 'instance' board is not private either.
|
||||
(currentBoard.permission === 'public' || currentBoard.permission === 'instance')
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ template(name="boardHeaderButtons")
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
i.fa(class="{{currentBoard.visibilityIcon}}")
|
||||
//- The SAME expression the tooltip uses, so the two cannot say
|
||||
different things - and both follow the board from Private to
|
||||
Public without a second list of words to keep in step.
|
||||
@@ -109,6 +109,15 @@ template(name="boardVisibilityList")
|
||||
i.fa.fa-check
|
||||
span.sub-name {{privateDesc}}
|
||||
if notAllowPrivateVisibilityOnly
|
||||
//- #3249: every signed-in user of the instance, never anonymous.
|
||||
li
|
||||
with "instance"
|
||||
a.js-select-visibility
|
||||
i.fa.fa-users
|
||||
| {{_ 'instance'}}
|
||||
if visibilityCheck
|
||||
i.fa.fa-check
|
||||
span.sub-name {{_ 'instance-desc'}}
|
||||
li
|
||||
with "public"
|
||||
a.js-select-visibility
|
||||
@@ -191,6 +200,10 @@ template(name="createBoardForm")
|
||||
span.fa.fa-globe.colorful
|
||||
= " "
|
||||
| {{{_ 'board-public-info'}}}
|
||||
else if $eq visibility 'instance'
|
||||
span.fa.fa-users.colorful
|
||||
= " "
|
||||
| {{{_ 'board-instance-info'}}}
|
||||
else
|
||||
span.fa.fa-lock.colorful
|
||||
= " "
|
||||
|
||||
@@ -96,7 +96,8 @@ Template.boardHeaderButtons.helpers({
|
||||
allowPrivateVisibilityOnly !== undefined &&
|
||||
allowPrivateVisibilityOnly.booleanValue &&
|
||||
currentBoard &&
|
||||
currentBoard.permission === 'public'
|
||||
// #3249: an 'instance' board is not private either.
|
||||
(currentBoard.permission === 'public' || currentBoard.permission === 'instance')
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -1409,7 +1409,7 @@ template(name="cardMorePopup")
|
||||
span.clearfix
|
||||
span {{_ 'link-card'}}
|
||||
= ' '
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
i.fa(class="{{currentBoard.visibilityIcon}}")
|
||||
input.inline-input(type="text" id="cardURL" readonly value="{{ originRelativeUrl }}" autofocus="autofocus")
|
||||
//- `.primary` is the themed button - the board's accent with white text -
|
||||
and is named in forms.css beside the other buttons that wear it, so the
|
||||
|
||||
@@ -325,7 +325,7 @@ template(name="listMorePopup")
|
||||
span.clearfix
|
||||
span {{_ 'link-list'}}
|
||||
= ' '
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
i.fa(class="{{currentBoard.visibilityIcon}}")
|
||||
//- #6459: was {{ rootUrl }}, a helper that does not exist anywhere, so the
|
||||
//- link input was always empty. absoluteUrl was then resolved through a
|
||||
//- CARD of this list, so the box showed a card's address - and nothing at
|
||||
|
||||
@@ -608,6 +608,10 @@ kbd {
|
||||
.fa.fa-bell-slash.colorful {
|
||||
color: #f44336;
|
||||
}
|
||||
/* #3249: visible to every signed-in user - between public and private. */
|
||||
.fa.fa-users.colorful {
|
||||
color: #e6a100;
|
||||
}
|
||||
.fa.fa-eye.colorful {
|
||||
color: #4336f4;
|
||||
}
|
||||
|
||||
@@ -829,6 +829,7 @@ function reportTablePageData(tmpl) {
|
||||
id: 'board-permission', label: 'Permission', options: [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'public', labelKey: 'public' },
|
||||
{ value: 'instance', labelKey: 'instance' },
|
||||
{ value: 'private', labelKey: 'private' },
|
||||
],
|
||||
}], cfg.filter.get()) : [],
|
||||
|
||||
@@ -934,6 +934,9 @@
|
||||
"previewClipboardImagePopup-title": "Preview",
|
||||
"private": "Private",
|
||||
"private-desc": "This board is private. Only people added to the board can view and edit it.",
|
||||
"instance": "Signed-in users",
|
||||
"instance-desc": "Every signed-in user of this WeKan can view this board. It is never shown to people who are not signed in. Only people added to the board can edit.",
|
||||
"board-instance-info": "This board will be visible to <strong>every signed-in user</strong>.",
|
||||
"profile": "Profile",
|
||||
"public": "Public",
|
||||
"public-desc": "This board is public. It's visible to anyone with the link and will show up in search engines like Google. Only people added to the board can edit.",
|
||||
|
||||
@@ -16,6 +16,7 @@ import Attachments, { normalizeRemovedFiles } from './attachments';
|
||||
import Boards from '/models/boards';
|
||||
import { allowIsBoardMember } from '/server/lib/utils';
|
||||
import { ensureIndex } from '/server/lib/mongoStartup';
|
||||
const { readableWithoutMembership } = require('/models/lib/boardPermission');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-only configuration
|
||||
@@ -273,7 +274,8 @@ Attachments.protected = async function (fileObj) {
|
||||
if (!board) {
|
||||
return false;
|
||||
}
|
||||
if (board.isPublic()) {
|
||||
// #3249: an 'instance' board is readable by any signed-in user.
|
||||
if (readableWithoutMembership(board.permission, !!this.userId)) {
|
||||
return true;
|
||||
}
|
||||
return board.hasMember(this.userId);
|
||||
|
||||
+22
-7
@@ -6,6 +6,7 @@ import { Random } from 'meteor/random';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { notHelperBoardTitle } = require('/models/lib/helperBoards');
|
||||
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
|
||||
const { BOARD_PERMISSIONS, readableWithoutMembership, withoutMembershipSelectors } = require('/models/lib/boardPermission');
|
||||
const boardViewSettings = require('/models/lib/boardViewSettings');
|
||||
import escapeForRegex from 'escape-string-regexp';
|
||||
import CustomFields from './customFields';
|
||||
@@ -341,7 +342,8 @@ Boards.attachSchema(
|
||||
* visibility of the board
|
||||
*/
|
||||
type: String,
|
||||
allowedValues: ['public', 'private'],
|
||||
// #3249: 'instance' = every signed-in user reads it (semi-open).
|
||||
allowedValues: BOARD_PERMISSIONS,
|
||||
},
|
||||
orgs: {
|
||||
/**
|
||||
@@ -1663,13 +1665,12 @@ Boards.helpers({
|
||||
* Is supplied user authorized to view this board?
|
||||
*/
|
||||
isVisibleBy(user) {
|
||||
if (this.isPublic()) {
|
||||
// public boards are visible to everyone
|
||||
// Public boards are visible to everyone, 'instance' boards (#3249) to
|
||||
// every signed-in user; otherwise you have to be an active member.
|
||||
if (readableWithoutMembership(this.permission, !!(user && user._id))) {
|
||||
return true;
|
||||
} else {
|
||||
// otherwise you have to be logged-in and active member
|
||||
return user && this.isActiveMember(user._id);
|
||||
}
|
||||
return user && this.isActiveMember(user._id);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1688,10 +1689,24 @@ Boards.helpers({
|
||||
}
|
||||
},
|
||||
|
||||
// Anybody, signed in or not. An 'instance' board is NOT public: code that
|
||||
// serves anonymous callers keeps asking this; code that knows its caller
|
||||
// asks isVisibleBy() or readableWithoutMembership().
|
||||
isPublic() {
|
||||
return this.permission === 'public';
|
||||
},
|
||||
|
||||
isInstanceBoard() {
|
||||
return this.permission === 'instance';
|
||||
},
|
||||
|
||||
// The icon shown beside the board's visibility (#3249).
|
||||
visibilityIcon() {
|
||||
if (this.permission === 'public') return 'fa-globe';
|
||||
if (this.permission === 'instance') return 'fa-users';
|
||||
return 'fa-lock';
|
||||
},
|
||||
|
||||
hasSharedListsConverted() {
|
||||
return this.hasSharedListsConverted === true;
|
||||
},
|
||||
@@ -3219,7 +3234,7 @@ Boards.userSearch = (
|
||||
projection = {},
|
||||
options = {},
|
||||
) => {
|
||||
selector.$or = options.includePublic === false ? [] : [{ permission: 'public' }];
|
||||
selector.$or = options.includePublic === false ? [] : withoutMembershipSelectors(!!userId);
|
||||
|
||||
if (userId) {
|
||||
selector.$or.push({ members: { $elemMatch: { userId, isActive: true } } });
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
// wekan/wekan#3249 (maintainer decision 2026-09-29): semi-open boards. A board's
|
||||
// `permission` is one of three:
|
||||
//
|
||||
// 'private' - only its active members (and org/team/domain shares) read it;
|
||||
// 'instance' - every signed-in user of this WeKan reads it, and it is never
|
||||
// in an unauthenticated response: not on the anonymous Public
|
||||
// page, not in link previews, not over REST without a token;
|
||||
// 'public' - anybody reads it, signed in or not.
|
||||
//
|
||||
// Reading is all 'instance' grants: editing still needs membership, exactly as
|
||||
// on a public board. This module is the one statement of that rule; it is pure
|
||||
// so tests/boardPermission.test.cjs checks it directly.
|
||||
|
||||
const BOARD_PERMISSIONS = ['private', 'instance', 'public'];
|
||||
|
||||
// May somebody read this board without being one of its members?
|
||||
// `signedIn` is whether the caller is an authenticated user.
|
||||
function readableWithoutMembership(permission, signedIn) {
|
||||
if (permission === 'public') return true;
|
||||
if (permission === 'instance') return !!signedIn;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The Mongo clauses for boards readable without membership - the counterpart
|
||||
// of readableWithoutMembership() for queries.
|
||||
function withoutMembershipSelectors(signedIn) {
|
||||
return signedIn ? [{ permission: 'public' }, { permission: 'instance' }] : [{ permission: 'public' }];
|
||||
}
|
||||
|
||||
// Anything but private. Admin Panel → Settings → Visibility "private boards
|
||||
// only" refuses both open kinds.
|
||||
function isOpenPermission(permission) {
|
||||
return permission === 'public' || permission === 'instance';
|
||||
}
|
||||
|
||||
module.exports = { BOARD_PERMISSIONS, readableWithoutMembership, withoutMembershipSelectors, isOpenPermission };
|
||||
@@ -1,3 +1,5 @@
|
||||
const { withoutMembershipSelectors } = require('./boardPermission');
|
||||
|
||||
// Pure, dependency-free construction of the Mongo `$or` that answers "which
|
||||
// boards may this user see". No Meteor imports, so it is unit tested directly
|
||||
// with plain Node (tests/boardVisibilitySelectors.test.cjs).
|
||||
@@ -39,8 +41,10 @@ function boardVisibilitySelectors({
|
||||
} = {}) {
|
||||
const selectors = [];
|
||||
|
||||
// #3249: 'instance' boards count as readable-without-membership for a
|
||||
// signed-in subscriber only (models/lib/boardPermission.js).
|
||||
if (includePublic && !membersOnly) {
|
||||
selectors.push({ permission: 'public' });
|
||||
selectors.push(...withoutMembershipSelectors(!!userId));
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
@@ -70,14 +74,16 @@ function boardVisibilitySelectors({
|
||||
return selectors;
|
||||
}
|
||||
|
||||
// A favorite is a user relationship only while the board remains public.
|
||||
// Requiring permission here prevents a stale star from restoring access to a
|
||||
// private board after its membership or share is revoked.
|
||||
// A favorite is a user relationship only while the board remains readable
|
||||
// without membership. Requiring permission here prevents a stale star from
|
||||
// restoring access to a private board after its membership or share is
|
||||
// revoked. Stars belong to signed-in users, so an 'instance' board (#3249)
|
||||
// counts too.
|
||||
function starredPublicBoardSelector(boardIds) {
|
||||
const ids = Array.isArray(boardIds)
|
||||
? boardIds.filter(id => typeof id === 'string' && id)
|
||||
: [];
|
||||
return ids.length ? { _id: { $in: ids }, permission: 'public' } : null;
|
||||
return ids.length ? { _id: { $in: ids }, permission: { $in: ['public', 'instance'] } } : null;
|
||||
}
|
||||
|
||||
module.exports = { boardVisibilitySelectors, starredPublicBoardSelector };
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
// A board export may predate the permission field (notably old Sandstorm
|
||||
// exports). Importing an absent or malformed value must fail closed: only an
|
||||
// explicit supported public value may create a public board.
|
||||
// explicit supported open value may create a public or instance-wide (#3249)
|
||||
// board. Admin Panel's "private boards only" still applies on insert.
|
||||
function importedBoardPermission(permission) {
|
||||
return permission === 'public' ? 'public' : 'private';
|
||||
return permission === 'public' || permission === 'instance' ? permission : 'private';
|
||||
}
|
||||
|
||||
export { importedBoardPermission };
|
||||
@@ -1,5 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
import boardPermission from './boardPermission.js';
|
||||
|
||||
const { readableWithoutMembership } = boardPermission;
|
||||
|
||||
// Pure helpers deciding which boards may be picked as a destination in the
|
||||
// IFTTT-Rules "move card to the board" / "link card to the board" selectors.
|
||||
// Extracted from client/components/rules/actions/boardActions.js so the choice
|
||||
@@ -22,7 +26,8 @@ function hasActive(list, predicate) {
|
||||
// emailDomains } — the last three are plain id arrays (empty when absent).
|
||||
function boardVisibleToUserContext(board, ctx) {
|
||||
if (!board || !ctx) return false;
|
||||
if (board.permission === 'public') return true;
|
||||
// #3249: public for anybody, 'instance' for a signed-in user.
|
||||
if (readableWithoutMembership(board.permission, !!ctx.userId)) return true;
|
||||
if (hasActive(board.members, m => m.userId === ctx.userId && !!m.isActive)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export class WekanCreator {
|
||||
// allowed values (is it worth the maintenance?)
|
||||
color: String,
|
||||
permission: Match.Where(value => {
|
||||
return ['private', 'public'].indexOf(value) >= 0;
|
||||
return ['private', 'instance', 'public'].indexOf(value) >= 0;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -67,6 +67,18 @@
|
||||
{
|
||||
"key": "r-trigger-vars-hint",
|
||||
"since": "2026-09-29"
|
||||
},
|
||||
{
|
||||
"key": "instance",
|
||||
"since": "2026-09-29"
|
||||
},
|
||||
{
|
||||
"key": "instance-desc",
|
||||
"since": "2026-09-29"
|
||||
},
|
||||
{
|
||||
"key": "board-instance-info",
|
||||
"since": "2026-09-29"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { createHash } from 'node:crypto';
|
||||
import Boards from '/models/boards';
|
||||
import { canReadBoard } from '/models/lib/boardVisibility';
|
||||
import { validateFilterPresetState } from '/models/lib/filterPresetState';
|
||||
const { withoutMembershipSelectors } = require('/models/lib/boardPermission');
|
||||
|
||||
const presets = new Mongo.Collection('savedCardFilters');
|
||||
async function authorize(userId, boardId) {
|
||||
@@ -38,7 +39,8 @@ publishComposite('savedCardFilters', function(boardId) {
|
||||
const ownerId = this.userId;
|
||||
if (!ownerId || !boardId) return;
|
||||
return {
|
||||
find: () => Boards.find({ _id: boardId, $or: [{ permission: 'public' },
|
||||
// #3249: signed in (checked above), so 'instance' boards count too.
|
||||
find: () => Boards.find({ _id: boardId, $or: [...withoutMembershipSelectors(true),
|
||||
{ members: { $elemMatch: { userId: ownerId, isActive: true } } }] }, { fields: { _id: 1 } }),
|
||||
children: [{ find: board => presets.find({ ownerId, boardId: board._id }, { sort: { name: 1 } }) }],
|
||||
};
|
||||
|
||||
+12
-6
@@ -36,6 +36,7 @@ import { recordRecoveryAudit } from '/server/lib/recoveryAudit';
|
||||
import { publicErrorData } from '/server/lib/apiResponseHelpers';
|
||||
|
||||
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
|
||||
const { readableWithoutMembership, withoutMembershipSelectors, isOpenPermission } = require('/models/lib/boardPermission');
|
||||
|
||||
function getTranslatedString(key, fallback, options) {
|
||||
const i18n = getTAPi18n && getTAPi18n();
|
||||
@@ -198,7 +199,7 @@ Meteor.methods({
|
||||
'tableVisibilityMode-allowPrivateOnly',
|
||||
);
|
||||
const permission = privateOnly?.booleanValue ? 'private' : requestedPermission;
|
||||
if (privateOnly?.booleanValue && requestedPermission === 'public') {
|
||||
if (privateOnly?.booleanValue && isOpenPermission(requestedPermission)) {
|
||||
try {
|
||||
require('/server/lib/securityLog').record({
|
||||
key: 'authz.board-visibility', action: 'blocked',
|
||||
@@ -616,7 +617,8 @@ Meteor.methods({
|
||||
{ $pull: { watchers: memberId } },
|
||||
{ multi: true },
|
||||
);
|
||||
if (!board.isPublic()) {
|
||||
// #3249: a star stays while the board remains readable to them.
|
||||
if (!readableWithoutMembership(board.permission, true)) {
|
||||
await Users.updateAsync(memberId, {
|
||||
$pull: { 'profile.starredBoards': currBoardId },
|
||||
});
|
||||
@@ -713,14 +715,14 @@ Meteor.methods({
|
||||
Boards.before.insert(async (userId, doc) => {
|
||||
// Trusted copies, imports and lazy helper creation bypass collection allow
|
||||
// rules too. Enforce the instance policy at the shared insertion boundary.
|
||||
if (doc.permission === 'public') {
|
||||
if (isOpenPermission(doc.permission)) {
|
||||
const privateOnly = await TableVisibilityModeSettings.findOneAsync('tableVisibilityMode-allowPrivateOnly');
|
||||
if (privateOnly?.booleanValue) {
|
||||
doc.permission = 'private';
|
||||
try {
|
||||
require('/server/lib/securityLog').record({
|
||||
key: 'authz.board-visibility', action: 'blocked', source: 'board:insert-policy',
|
||||
detail: 'Public board insertion overridden by private-only policy.',
|
||||
detail: 'Public or instance-wide board insertion overridden by private-only policy.',
|
||||
});
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
}
|
||||
@@ -821,7 +823,8 @@ Boards.before.update((userId, doc, fieldNames, modifier) => {
|
||||
const board = Boards._transform(doc);
|
||||
await board.setWatcher(memberId, false);
|
||||
|
||||
if (!board.isPublic()) {
|
||||
// #3249: a star stays while the board remains readable to them.
|
||||
if (!readableWithoutMembership(board.permission, true)) {
|
||||
await Users.updateAsync(memberId, {
|
||||
$pull: {
|
||||
'profile.starredBoards': boardId,
|
||||
@@ -949,8 +952,9 @@ WebApp.handlers.get('/api/users/:userId/boards', async function(req, res) {
|
||||
WebApp.handlers.get('/api/boards', async function(req, res) {
|
||||
try {
|
||||
await Authentication.checkUserId(req.userId);
|
||||
// #3249: the caller is authenticated, so 'instance' boards are listed too.
|
||||
const boards = await ReactiveCache.getBoards(
|
||||
{ permission: 'public' },
|
||||
{ $or: withoutMembershipSelectors(true) },
|
||||
{
|
||||
sort: { sort: 1 },
|
||||
},
|
||||
@@ -974,11 +978,13 @@ WebApp.handlers.get('/api/boards_count', async function(req, res) {
|
||||
await Authentication.checkUserId(req.userId);
|
||||
const privateBoards = await ReactiveCache.getBoards({ permission: 'private' });
|
||||
const publicBoards = await ReactiveCache.getBoards({ permission: 'public' });
|
||||
const instanceBoards = await ReactiveCache.getBoards({ permission: 'instance' });
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
private: privateBoards.length,
|
||||
public: publicBoards.length,
|
||||
instance: instanceBoards.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -47,6 +47,7 @@ import { canEditCardOrLinkedCard } from '/server/lib/linkedCardPermission';
|
||||
import getSlug from 'limax';
|
||||
|
||||
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
|
||||
const { isOpenPermission } = require('/models/lib/boardPermission');
|
||||
|
||||
function getTranslatedString(key, fallback, options) {
|
||||
const i18n = getTAPi18n && getTAPi18n();
|
||||
@@ -94,9 +95,9 @@ Meteor.methods({
|
||||
const privateOnly = await TableVisibilityModeSettings.findOneAsync(
|
||||
'tableVisibilityMode-allowPrivateOnly',
|
||||
);
|
||||
const requestedPermission = sourceBoard.permission === 'public' ? 'public' : 'private';
|
||||
const requestedPermission = isOpenPermission(sourceBoard.permission) ? sourceBoard.permission : 'private';
|
||||
const permission = privateOnly?.booleanValue ? 'private' : requestedPermission;
|
||||
if (privateOnly?.booleanValue && requestedPermission === 'public') {
|
||||
if (privateOnly?.booleanValue && isOpenPermission(requestedPermission)) {
|
||||
try {
|
||||
require('/server/lib/securityLog').record({
|
||||
key: 'authz.board-visibility', action: 'blocked',
|
||||
|
||||
@@ -4,13 +4,15 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { getFeatureFlags } from '/models/lib/featureFlags';
|
||||
import Boards from '/models/boards';
|
||||
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
|
||||
const { readableWithoutMembership } = require('/models/lib/boardPermission');
|
||||
|
||||
// True when this user may see this board: public, a member, or an ACTIVE
|
||||
// organisation / team / email-domain share. Built from the same selectors the
|
||||
// board publication uses, so the two cannot drift apart.
|
||||
async function canSeeBoard(userId, board) {
|
||||
if (!board) return false;
|
||||
if (board.permission === 'public') return true;
|
||||
// #3249: public for anybody, 'instance' for a signed-in user.
|
||||
if (readableWithoutMembership(board.permission, !!userId)) return true;
|
||||
if (!userId) return false;
|
||||
if (typeof board.hasMember === 'function' && board.hasMember(userId)) return true;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { findWhere, where } from '/imports/lib/collectionHelpers';
|
||||
import { allowIsBoardAdminOrSiteAdmin, canUpdateBoardSort, canUpdateBoardSameWidthValue } from '/server/lib/utils';
|
||||
import { canWriteSubtaskDeposit, recordSubtaskDepositDenial } from '/server/lib/subtaskDepositAccess';
|
||||
const { isOpenPermission } = require('/models/lib/boardPermission');
|
||||
|
||||
Boards.deny({
|
||||
async insert(userId, doc) {
|
||||
@@ -31,7 +32,8 @@ Boards.allow({
|
||||
|
||||
// If allowPrivateOnly is enabled, only allow private boards
|
||||
const allowPrivateOnly = (await TableVisibilityModeSettings.findOneAsync('tableVisibilityMode-allowPrivateOnly'))?.booleanValue;
|
||||
if (allowPrivateOnly && doc.permission === 'public') {
|
||||
// #3249: an 'instance' board is not private either.
|
||||
if (allowPrivateOnly && isOpenPermission(doc.permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -124,7 +126,7 @@ Boards.deny({
|
||||
if (!(fieldNames || []).includes('permission')) return false;
|
||||
|
||||
const allowPrivateOnly = (await TableVisibilityModeSettings.findOneAsync('tableVisibilityMode-allowPrivateOnly'))?.booleanValue;
|
||||
if (allowPrivateOnly && modifier.$set && modifier.$set.permission === 'public') {
|
||||
if (allowPrivateOnly && modifier.$set && isOpenPermission(modifier.$set.permission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Attachments from '/models/attachments';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
|
||||
const { readableWithoutMembership } = require('/models/lib/boardPermission');
|
||||
|
||||
// Publish a board's background images so the board-settings backgrounds list
|
||||
// can show them. Board backgrounds are stored as board-level Attachments
|
||||
@@ -14,9 +15,10 @@ Meteor.publish('boardBackgrounds', async function (boardId) {
|
||||
if (!board) {
|
||||
return this.ready();
|
||||
}
|
||||
const isPublic = board.isPublic && board.isPublic();
|
||||
// Signed in (checked above), so an 'instance' board (#3249) is readable too.
|
||||
const readable = readableWithoutMembership(board.permission, true);
|
||||
const isMember = board.hasMember && board.hasMember(this.userId);
|
||||
if (!isPublic && !isMember) {
|
||||
if (!readable && !isMember) {
|
||||
return this.ready();
|
||||
}
|
||||
return Attachments.collection.find(liveAttachments({
|
||||
|
||||
@@ -180,10 +180,12 @@ Meteor.publish('boardTemplates', async function() {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The boards /public may show: public, not archived, a real board rather than a
|
||||
// template container, and not one of WeKan's internal helper boards.
|
||||
function publicBoardsSelector(searchTerm) {
|
||||
// template container, and not one of WeKan's internal helper boards. A
|
||||
// signed-in visitor also sees the 'instance' boards (#3249); who is signed in
|
||||
// is the server's own `this.userId`, never something the client says.
|
||||
function publicBoardsSelector(searchTerm, signedIn = false) {
|
||||
const query = {
|
||||
permission: 'public',
|
||||
permission: signedIn ? { $in: ['public', 'instance'] } : 'public',
|
||||
archived: false,
|
||||
type: 'board',
|
||||
title: notHelperBoardTitle(),
|
||||
@@ -225,7 +227,7 @@ Meteor.publish('publicBoards', async function(searchTerm = '', limit = 10, skip
|
||||
// `members` in particular is deliberately absent: it is the largest field on a
|
||||
// busy board and this page shows no avatars.
|
||||
const boards = await ReactiveCache.getBoards(
|
||||
publicBoardsSelector(searchTerm),
|
||||
publicBoardsSelector(searchTerm, !!this.userId),
|
||||
{
|
||||
fields: {
|
||||
_id: 1,
|
||||
@@ -252,9 +254,10 @@ Meteor.publish('publicBoards', async function(searchTerm = '', limit = 10, skip
|
||||
Meteor.methods({
|
||||
async getPublicBoardsCount(searchTerm = '') {
|
||||
check(searchTerm, Match.OneOf(String, null, undefined));
|
||||
// No authorization check, deliberately: this counts PUBLIC boards, which is
|
||||
// the same set the publication above will send to the same caller.
|
||||
const cursor = await ReactiveCache.getBoards(publicBoardsSelector(searchTerm), {}, true);
|
||||
// No authorization check, deliberately: this counts the same set the
|
||||
// publication above sends to the same caller - public boards, plus the
|
||||
// 'instance' ones when that caller is signed in.
|
||||
const cursor = await ReactiveCache.getBoards(publicBoardsSelector(searchTerm, !!this.userId), {}, true);
|
||||
return typeof cursor.countAsync === 'function'
|
||||
? await cursor.countAsync()
|
||||
: cursor.count();
|
||||
@@ -266,7 +269,7 @@ function boardsReportQuery(searchTerm = '', permission = 'all') {
|
||||
if (searchTerm) {
|
||||
query.title = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
||||
}
|
||||
if (permission === 'public' || permission === 'private') {
|
||||
if (permission === 'public' || permission === 'private' || permission === 'instance') {
|
||||
query.permission = permission;
|
||||
}
|
||||
return query;
|
||||
@@ -274,7 +277,7 @@ function boardsReportQuery(searchTerm = '', permission = 'all') {
|
||||
|
||||
Meteor.publish('boardsReport', async function(searchTerm = '', permission = 'all', limit, skip = 0) {
|
||||
check(searchTerm, Match.OneOf(String, null, undefined));
|
||||
check(permission, Match.OneOf('all', 'public', 'private'));
|
||||
check(permission, Match.OneOf('all', 'public', 'private', 'instance'));
|
||||
check(limit, Number);
|
||||
check(skip, Match.OneOf(Number, null, undefined));
|
||||
// An ADMIN report, over the whole instance - like the Cards report beside it in
|
||||
@@ -358,7 +361,7 @@ Meteor.publish('boardsReport', async function(searchTerm = '', permission = 'all
|
||||
Meteor.methods({
|
||||
async getBoardsReportCount(searchTerm = '', permission = 'all') {
|
||||
check(searchTerm, Match.OneOf(String, null, undefined));
|
||||
check(permission, Match.OneOf('all', 'public', 'private'));
|
||||
check(permission, Match.OneOf('all', 'public', 'private', 'instance'));
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
|
||||
@@ -85,6 +85,7 @@ import Team from "../../models/team";
|
||||
import { MATCH_NOTHING, selectorIsInjection } from '/server/lib/selectorGuard';
|
||||
const { boardCardScope } = require('/models/lib/boardCardScope');
|
||||
const { retainRankedCard } = require('/models/lib/cardSearchRanking');
|
||||
const { withoutMembershipSelectors } = require('/models/lib/boardPermission');
|
||||
const {
|
||||
ownedSearchSessionSelector,
|
||||
recordLoggedOutPaginationProbe,
|
||||
@@ -355,9 +356,10 @@ Meteor.publish('dueCards', async function(allUsers = false, limit = 200, skip =
|
||||
}
|
||||
|
||||
// Get user's board memberships for efficient filtering
|
||||
// #3249: signed in, so 'instance' boards count as well.
|
||||
const userBoards = (await ReactiveCache.getBoards({
|
||||
$or: [
|
||||
{ permission: 'public' },
|
||||
...withoutMembershipSelectors(true),
|
||||
{ members: { $elemMatch: { userId, isActive: true } } }
|
||||
]
|
||||
})).map(board => board._id);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DocumentPreviews, indexDocumentText } from '/server/lib/documentGif';
|
||||
import { boundedStreamBuffer, gifCacheKey } from '/server/lib/imageGif';
|
||||
import { convertImageBufferToThumbnail, cachedThumbnail, rememberThumbnail } from '/server/lib/imageThumbnail';
|
||||
const { isThumbnailPath, canThumbnail, THUMBNAIL_TYPE } = require('/models/lib/attachmentThumbnail');
|
||||
const { readableWithoutMembership } = require('/models/lib/boardPermission');
|
||||
|
||||
async function normalizeStoredNameOnRead(collection, fileObj, factory) {
|
||||
if (!fileObj) return fileObj;
|
||||
@@ -316,10 +317,12 @@ if (Meteor.isServer) {
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (board && board.isPublic && board.isPublic()) return true;
|
||||
|
||||
// If private board is specified, require membership of requester
|
||||
// Otherwise require a signed-in requester who may read the board: a
|
||||
// member, or anybody signed in on an 'instance' board (#3249).
|
||||
const token = extractLoginToken(req);
|
||||
const user = token ? await getUserFromToken(token) : null;
|
||||
if (user && board && board.hasMember && board.hasMember(user._id)) return true;
|
||||
if (user && board && readableWithoutMembership(board.permission, true)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,8 @@ function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
const at = src.indexOf(marker);
|
||||
assert.ok(at !== -1, `${marker} must exist`);
|
||||
// The guard must be at the TOP of the publication, before it reads anything.
|
||||
assert.ok(/isAdmin/.test(src.slice(at, at + 400)), `${marker} must be admin-only`);
|
||||
// (500: the boardsReport argument checks grew by the #3249 'instance' filter.)
|
||||
assert.ok(/isAdmin/.test(src.slice(at, at + 500)), `${marker} must be admin-only`);
|
||||
}
|
||||
for (const [file, method] of [
|
||||
['server/publications/boards.js', 'getBoardsReportCount'],
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
// wekan/wekan#3249: semi-open boards - permission 'instance', readable by every
|
||||
// signed-in user and never in an unauthenticated response.
|
||||
// Run: node tests/boardPermission.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { BOARD_PERMISSIONS, readableWithoutMembership, withoutMembershipSelectors, isOpenPermission } =
|
||||
require('../models/lib/boardPermission.js');
|
||||
const { boardVisibilitySelectors, starredPublicBoardSelector } = require('../models/lib/boardVisibilitySelectors.js');
|
||||
const { importedBoardPermission } = require('../models/lib/importedBoardPermission.js');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
|
||||
// The rule.
|
||||
assert.deepEqual(BOARD_PERMISSIONS, ['private', 'instance', 'public']);
|
||||
assert.equal(readableWithoutMembership('public', false), true);
|
||||
assert.equal(readableWithoutMembership('public', true), true);
|
||||
assert.equal(readableWithoutMembership('instance', true), true);
|
||||
assert.equal(readableWithoutMembership('instance', false), false, 'never for somebody signed out');
|
||||
for (const other of ['private', undefined, null, 'Instance', 'org', '']) {
|
||||
assert.equal(readableWithoutMembership(other, true), false, String(other));
|
||||
}
|
||||
assert.deepEqual(withoutMembershipSelectors(false), [{ permission: 'public' }]);
|
||||
assert.deepEqual(withoutMembershipSelectors(true), [{ permission: 'public' }, { permission: 'instance' }]);
|
||||
assert.ok(isOpenPermission('public') && isOpenPermission('instance') && !isOpenPermission('private'));
|
||||
console.log(' ok - instance boards are readable signed in, never signed out');
|
||||
|
||||
// The shared selector builder: instance only with a user, and it drops out
|
||||
// with public from relationship-only lists and searches.
|
||||
const has = (clauses, permission) => clauses.some(c => c.permission === permission);
|
||||
assert.ok(!has(boardVisibilitySelectors({}), 'instance'), 'anonymous subscriber');
|
||||
assert.ok(has(boardVisibilitySelectors({ userId: 'u' }), 'instance'));
|
||||
assert.ok(!has(boardVisibilitySelectors({ userId: 'u', includePublic: false }), 'instance'));
|
||||
assert.ok(!has(boardVisibilitySelectors({ userId: 'u', membersOnly: true }), 'instance'));
|
||||
assert.deepEqual(starredPublicBoardSelector(['b']).permission, { $in: ['public', 'instance'] });
|
||||
// Import keeps it, and still fails closed on anything unknown.
|
||||
assert.equal(importedBoardPermission('instance'), 'instance');
|
||||
assert.equal(importedBoardPermission('public'), 'public');
|
||||
for (const bad of [undefined, 'org', 'INSTANCE', {}]) assert.equal(importedBoardPermission(bad), 'private');
|
||||
console.log(' ok - the shared selectors and import follow the same rule');
|
||||
|
||||
// The object check and the schema.
|
||||
const boards = read('models/boards.js');
|
||||
assert.match(boards, /allowedValues: BOARD_PERMISSIONS,/);
|
||||
assert.match(boards, /isVisibleBy\(user\) \{[\s\S]*?if \(readableWithoutMembership\(this\.permission, !!\(user && user\._id\)\)\) \{\s*return true;\s*\}\s*return user && this\.isActiveMember\(user\._id\);/);
|
||||
assert.match(boards, /isPublic\(\) \{\s*return this\.permission === 'public';/, 'isPublic still means anybody');
|
||||
console.log(' ok - isVisibleBy grants instance boards to a signed-in user only');
|
||||
|
||||
// Negative, tree-wide: every remaining "public" check in server and model code
|
||||
// is either an anonymous path that must stay public-only, or text. A new read
|
||||
// gate written as `isPublic()` would silently leave instance boards out (or,
|
||||
// written the other way, let them out anonymously), so each one is listed.
|
||||
const REVIEWED = {
|
||||
'models/avatars.server.js': [1, 'an avatar is anonymous-readable only through a PUBLIC board'],
|
||||
'server/routes/avatarServer.js': [1, 'same, on the avatar route'],
|
||||
'server/routes/universalFileServer.js': [2, 'anonymous avatar reads; a signed-in reader uses readableWithoutMembership'],
|
||||
'server/lib/cardOgTags.js': [2, 'link previews are unauthenticated, so public only'],
|
||||
'server/routes/cardOgTags.js': [1, 'comment about the same'],
|
||||
'models/export.js': [7, 'the no-token branch; instance boards authenticate and pass isVisibleBy'],
|
||||
'models/exportCharts.js': [1, 'same'],
|
||||
'models/exportExcel.js': [1, 'same'],
|
||||
'models/exportExcelCard.js': [2, 'same (one is a comment)'],
|
||||
'models/exportPDF.js': [2, 'same'],
|
||||
'models/boards.js': [4, 'isPublic, visibilityIcon and a comment'],
|
||||
'models/lib/boardViewSettings.js': [3, 'per-visibility views; instance uses the private side'],
|
||||
'models/lib/boardVisibilitySelectors.js': [1, 'comment'],
|
||||
'models/lib/importedBoardPermission.js': [1, 'import keeps public and instance'],
|
||||
'models/trelloCreator.js': [1, 'Trello visibility mapping; Trello "org" stays private'],
|
||||
'server/lib/schemaUpgradeSteps.js': [1, 'case repair of stored values'],
|
||||
'server/models/boards.js': [1, '/api/boards_count counts each kind'],
|
||||
'server/publications/boards.js': [1, 'the admin report filter lists instance beside it'],
|
||||
};
|
||||
const PATTERN = "isPublic\\(\\)|permission: 'public'|permission ?===? ?'public'|=== 'public'";
|
||||
const hits = execFileSync('git', ['grep', '-cE', PATTERN, '--', 'server', 'models', ':!**/tests/**'], { cwd: ROOT })
|
||||
.toString().trim().split('\n').filter(Boolean)
|
||||
.map(line => { const i = line.lastIndexOf(':'); return [line.slice(0, i), Number(line.slice(i + 1))]; });
|
||||
const unexpected = hits.filter(([file, count]) => !REVIEWED[file] || REVIEWED[file][0] !== count);
|
||||
assert.deepEqual(unexpected, [], 'a public-only check was added or removed; review it for instance boards and update this list');
|
||||
for (const file of Object.keys(REVIEWED)) {
|
||||
assert.ok(hits.some(([f]) => f === file), `${file} no longer has its reviewed check - remove it from the list`);
|
||||
}
|
||||
console.log(' ok - every remaining public-only check is a reviewed anonymous path');
|
||||
|
||||
// The anonymous paths really stay public-only.
|
||||
assert.match(read('server/lib/cardOgTags.js'), /if \(typeof board\.isPublic !== 'function' \|\| !board\.isPublic\(\)\) return null;/);
|
||||
assert.match(read('server/publications/cards.js'), /if \(!userId\) \{[\s\S]{0,200}if \(board\.permission !== 'public'\) return this\.ready\(\);/);
|
||||
assert.match(read('server/publications/boards.js'), /permission: signedIn \? \{ \$in: \['public', 'instance'\] \} : 'public'/);
|
||||
console.log(' ok - link previews and anonymous publications never include instance boards');
|
||||
@@ -8,34 +8,44 @@ const end = source.indexOf(' const boardId = await Boards.insertAsync({', sta
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const prefix = source.slice(start, end).replace(' async createBoardWithInitialSwimlanes(payload) {', '');
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const run = new AsyncFunction('payload', 'check', 'Match', 'Meteor', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', prefix + '\nreturn permission;');
|
||||
// #3249: "private boards only" refuses 'instance' boards as well as public
|
||||
// ones; the server code asks isOpenPermission() for both.
|
||||
const { isOpenPermission } = require('../models/lib/boardPermission.js');
|
||||
const run = new AsyncFunction('payload', 'check', 'Match', 'Meteor', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', 'isOpenPermission', prefix + '\nreturn permission;');
|
||||
(async () => {
|
||||
for (const privateOnly of [false, true]) {
|
||||
for (const requested of ['private', 'public']) {
|
||||
for (const requested of ['private', 'public', 'instance']) {
|
||||
const logs = [];
|
||||
const settings = { findOneAsync: async key => ({ booleanValue: key === 'tableVisibilityMode-allowPrivateOnly' && privateOnly }) };
|
||||
const result = await run.call({ userId: 'creator' }, { title: 'T', slug: 't', permission: requested }, () => {}, { ObjectIncluding: x => x, Maybe: x => x }, {}, settings, {}, () => ({ record: x => logs.push(x) }));
|
||||
const result = await run.call({ userId: 'creator' }, { title: 'T', slug: 't', permission: requested }, () => {}, { ObjectIncluding: x => x, Maybe: x => x }, {}, settings, {}, () => ({ record: x => logs.push(x) }), isOpenPermission);
|
||||
assert.equal(result, privateOnly ? 'private' : requested);
|
||||
assert.equal(logs.length, privateOnly && requested === 'public' ? 1 : 0, 'only blocked public requests are logged');
|
||||
assert.equal(logs.length, privateOnly && requested !== 'private' ? 1 : 0, 'only blocked public or instance requests are logged');
|
||||
}
|
||||
}
|
||||
const cards = fs.readFileSync(require('node:path').join(__dirname, '../server/models/cards.js'), 'utf8');
|
||||
const at = cards.indexOf(" const privateOnly = await TableVisibilityModeSettings.findOneAsync(");
|
||||
assert.ok(at > 0);
|
||||
const conversion = new AsyncFunction('sourceBoard', 'TableVisibilityModeSettings', 'require', cards.slice(at, cards.indexOf(' const boardId = await Boards.insertAsync({', at)) + '\nreturn permission;');
|
||||
const conversion = new AsyncFunction('sourceBoard', 'TableVisibilityModeSettings', 'require', 'isOpenPermission', cards.slice(at, cards.indexOf(' const boardId = await Boards.insertAsync({', at)) + '\nreturn permission;');
|
||||
for (const enabled of [false, true]) {
|
||||
const settings = { findOneAsync: async () => ({ booleanValue: enabled }) };
|
||||
assert.equal(await conversion({ permission: 'public' }, settings, () => ({ record() { throw Error('logger unavailable'); } })), enabled ? 'private' : 'public', 'conversion policy survives logging failure');
|
||||
for (const inherited of ['public', 'instance']) {
|
||||
assert.equal(await conversion({ permission: inherited }, settings, () => ({ record() { throw Error('logger unavailable'); } }), isOpenPermission),
|
||||
enabled ? 'private' : inherited, 'conversion policy survives logging failure');
|
||||
}
|
||||
assert.equal(await conversion({ permission: 'something' }, settings, () => ({ record() {} }), isOpenPermission), 'private',
|
||||
'an unknown source permission falls back to private');
|
||||
}
|
||||
assert.ok(!/permission:\s*sourceBoard\.permission/.test(cards), 'negative: conversion no longer copies visibility without policy');
|
||||
assert.ok(source.indexOf("'tableVisibilityMode-allowPrivateOnly'", start) < end, 'negative: method policy runs before insert');
|
||||
const hookAt = source.indexOf('Boards.before.insert(async (userId, doc) => {');
|
||||
const hookEnd = source.indexOf('\n});', hookAt);
|
||||
const hook = new AsyncFunction('userId', 'doc', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', source.slice(source.indexOf('\n', hookAt) + 1, hookEnd));
|
||||
const hook = new AsyncFunction('userId', 'doc', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', 'isOpenPermission', source.slice(source.indexOf('\n', hookAt) + 1, hookEnd));
|
||||
for (const enabled of [false, true]) {
|
||||
const doc = { permission: 'public' };
|
||||
await hook('u', doc, { findOneAsync: async () => ({ booleanValue: enabled }) }, { getBoard: async () => null }, () => ({ record() { throw Error('logger unavailable'); } }));
|
||||
assert.equal(doc.permission, enabled ? 'private' : 'public', 'shared copy/import/helper insert policy survives logger failure');
|
||||
for (const permission of ['public', 'instance']) {
|
||||
const doc = { permission };
|
||||
await hook('u', doc, { findOneAsync: async () => ({ booleanValue: enabled }) }, { getBoard: async () => null }, () => ({ record() { throw Error('logger unavailable'); } }), isOpenPermission);
|
||||
assert.equal(doc.permission, enabled ? 'private' : permission, 'shared copy/import/helper insert policy survives logger failure');
|
||||
}
|
||||
}
|
||||
console.log('boardPrivateOnlyMethod: server decision preserves private-only policy');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -49,7 +49,9 @@ test('watching asks whether the user may SEE the board', () => {
|
||||
test('the four ways a board is shared are all covered', () => {
|
||||
const fn = method.slice(method.indexOf('async function canSeeBoard'));
|
||||
const body = fn.slice(0, fn.indexOf('\n}'));
|
||||
assert.ok(/permission === 'public'/.test(body), 'public');
|
||||
// #3249: public for anybody and 'instance' for a signed-in user, both from
|
||||
// models/lib/boardPermission.js.
|
||||
assert.ok(/readableWithoutMembership\(board\.permission, !!userId\)/.test(body), 'public and instance-wide');
|
||||
assert.ok(/hasMember\(userId\)/.test(body), 'membership');
|
||||
assert.ok(/orgIds/.test(body) && /teamIds/.test(body) && /emailDomains/.test(body),
|
||||
'organisation, team and email domain');
|
||||
|
||||
@@ -29,12 +29,13 @@ test('publication and count share a validated server-side selector', () => {
|
||||
const server = read('server/publications/boards.js');
|
||||
assert.match(server, /function boardsReportQuery\(searchTerm = '', permission = 'all'\)/);
|
||||
assert.match(server,
|
||||
/permission === 'public' \|\| permission === 'private'[\s\S]*query\.permission = permission/);
|
||||
/permission === 'public' \|\| permission === 'private' \|\| permission === 'instance'[\s\S]*query\.permission = permission/);
|
||||
assert.match(server,
|
||||
/Meteor\.publish\('boardsReport', async function\(searchTerm = '', permission = 'all', limit, skip = 0\)/);
|
||||
assert.match(server, /async getBoardsReportCount\(searchTerm = '', permission = 'all'\)/);
|
||||
assert.strictEqual((server.match(/boardsReportQuery\(searchTerm, permission\)/g) || []).length, 2);
|
||||
assert.strictEqual((server.match(/check\(permission, Match\.OneOf\('all', 'public', 'private'\)\)/g) || []).length, 2,
|
||||
// #3249 added 'instance' boards to the filter.
|
||||
assert.strictEqual((server.match(/check\(permission, Match\.OneOf\('all', 'public', 'private', 'instance'\)\)/g) || []).length, 2,
|
||||
'forged filter values must be rejected in both endpoints');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
'use strict';
|
||||
// #3249: a board visible to every signed-in user of the instance, never to
|
||||
// somebody who is not signed in, and editable only by its members.
|
||||
const { request: playwrightRequest } = require('@playwright/test');
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken, openBoard } = require('../helpers/auth');
|
||||
|
||||
// Subscribe to the board as this page's user and report what arrived.
|
||||
const boardSeen = (page, boardId) => page.evaluate(id => new Promise(resolve => {
|
||||
const handle = Meteor.subscribe('board', id, false, {
|
||||
onReady() {
|
||||
// Meteor's client-side copies of what the server published.
|
||||
const local = Meteor.connection._mongo_livedata_collections;
|
||||
const seen = !!local.boards?.findOne({ _id: id }) && (local.cards?.find({ boardId: id }).count() || 0);
|
||||
// Resolve BEFORE stopping: stop() calls onStop at once, which would
|
||||
// otherwise answer false first and make every negative meaningless.
|
||||
resolve(seen);
|
||||
handle.stop();
|
||||
},
|
||||
onStop() { resolve(false); },
|
||||
});
|
||||
}), boardId);
|
||||
|
||||
test('the owner can make a board visible to signed-in users', async ({ boardPage: page, board }) => {
|
||||
await page.locator('.board-header-btn.js-change-visibility').first().click();
|
||||
await page.locator('.js-select-visibility', { hasText: /signed-in users/i }).click();
|
||||
await expect.poll(() => db.findOne('boards', { _id: board.boardId }).permission).toBe('instance');
|
||||
await expect(page.locator('.board-header-btn.js-change-visibility i.fa-users')).toBeVisible();
|
||||
});
|
||||
|
||||
test('any signed-in user reads it, only members edit it', async ({ page, board, user2 }) => {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'instance' } });
|
||||
await loginWithToken(page, user2.id, user2.token);
|
||||
await openBoard(page, board.boardId, board.slug);
|
||||
await expect(page.locator('.minicard', { hasText: 'Alpha Card' })).toBeVisible();
|
||||
|
||||
// Listed for them on /public (the fixture does not carry the title).
|
||||
const title = db.findOne('boards', { _id: board.boardId }).title;
|
||||
await page.goto('/public', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('a', { hasText: title }).first()).toBeVisible();
|
||||
|
||||
// Negative: reading is all it grants.
|
||||
const write = await page.request.post(`/api/boards/${board.boardId}/lists/${board.listIds[0]}/cards`, {
|
||||
headers: { Authorization: `Bearer ${user2.token}`, 'Content-Type': 'application/json' },
|
||||
data: { authorId: user2.id, swimlaneId: board.swimlaneId, title: 'Not a member' },
|
||||
});
|
||||
expect(write.status()).not.toBe(200);
|
||||
expect(db.findOne('cards', { boardId: board.boardId, title: 'Not a member' })).toBeNull();
|
||||
|
||||
// Negative: back to private, and the same user sees nothing.
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'private' } });
|
||||
expect(await boardSeen(page, board.boardId)).toBeFalsy();
|
||||
});
|
||||
|
||||
test('nobody signed out sees it anywhere', async ({ browser, board, baseURL }) => {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'instance' } });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const anonymous = await playwrightRequest.newContext({ baseURL });
|
||||
try {
|
||||
// Signed out, /public sends the visitor to sign in; ask the server directly.
|
||||
await page.goto('/public', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof Meteor !== 'undefined' && Meteor.status().connected);
|
||||
expect(await boardSeen(page, board.boardId)).toBeFalsy();
|
||||
expect(await page.evaluate(() => Meteor.callAsync('getPublicBoardsCount', ''))).toBe(
|
||||
db.find('boards', { permission: 'public', archived: false, type: 'board', title: { $not: /^\^.*\^$/ } }).length);
|
||||
// Export and card preview routes answer nothing without a token.
|
||||
const exported = await anonymous.get(`/api/boards/${board.boardId}/export`);
|
||||
expect(exported.status()).not.toBe(200);
|
||||
// The same board made public IS visible - the negative above is the rule, not a broken page.
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'public' } });
|
||||
expect(await boardSeen(page, board.boardId)).toBeTruthy();
|
||||
} finally {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'private' } });
|
||||
await anonymous.dispose();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('"private boards only" refuses an instance-wide board too', async ({ boardPage: page, board }) => {
|
||||
const id = 'tableVisibilityMode-allowPrivateOnly';
|
||||
const prior = db.findOne('tableVisibilityModeSettings', { _id: id });
|
||||
if (prior) db.updateOne('tableVisibilityModeSettings', { _id: id }, { $set: { booleanValue: true } });
|
||||
else db.insertOne('tableVisibilityModeSettings', { _id: id, booleanValue: true, sort: 0 });
|
||||
const created = [];
|
||||
try {
|
||||
const result = await page.evaluate(() => window.Meteor.callAsync('createBoardWithInitialSwimlanes',
|
||||
{ title: 'Instance policy test', slug: 'instance-policy-test', permission: 'instance' }));
|
||||
const boardId = typeof result === 'string' ? result : result.boardId;
|
||||
created.push(boardId);
|
||||
expect(db.findOne('boards', { _id: boardId }).permission).toBe('private');
|
||||
// And switching an existing board is refused by the update rule.
|
||||
const switched = await page.evaluate(boardId => new Promise(resolve => {
|
||||
window.Meteor.connection._mongo_livedata_collections.boards && window.Meteor.call(
|
||||
'/boards/update', { _id: boardId }, { $set: { permission: 'instance' } }, {}, error => resolve(error ? 'refused' : 'allowed'));
|
||||
}), board.boardId);
|
||||
expect(switched).toBe('refused');
|
||||
expect(db.findOne('boards', { _id: board.boardId }).permission).toBe('private');
|
||||
} finally {
|
||||
db.cleanup({ boardIds: created.filter(Boolean) });
|
||||
if (prior) db.updateOne('tableVisibilityModeSettings', { _id: id }, { $set: { booleanValue: prior.booleanValue } });
|
||||
else db.deleteOne('tableVisibilityModeSettings', { _id: id });
|
||||
}
|
||||
});
|
||||
@@ -6,8 +6,11 @@ const { test } = require('node:test');
|
||||
const { starredPublicBoardSelector } = require('../models/lib/boardVisibilitySelectors');
|
||||
|
||||
test('a starred public board has a narrow selector', () => {
|
||||
// #3249: a star belongs to a signed-in user (the publication refuses
|
||||
// anonymous subscribers), so a starred 'instance' board counts as well;
|
||||
// a private one still never does.
|
||||
assert.deepEqual(starredPublicBoardSelector(['board1', 'board2']), {
|
||||
_id: { $in: ['board1', 'board2'] }, permission: 'public',
|
||||
_id: { $in: ['board1', 'board2'] }, permission: { $in: ['public', 'instance'] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +18,7 @@ test('empty or invalid star lists cannot select arbitrary boards', () => {
|
||||
assert.equal(starredPublicBoardSelector([]), null);
|
||||
assert.equal(starredPublicBoardSelector(null), null);
|
||||
assert.deepEqual(starredPublicBoardSelector(['', null, 3, 'board1']), {
|
||||
_id: { $in: ['board1'] }, permission: 'public',
|
||||
_id: { $in: ['board1'] }, permission: { $in: ['public', 'instance'] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +27,8 @@ test('the publication, page query, and client subscription use the public-only s
|
||||
const header = fs.readFileSync('client/components/main/header.js', 'utf8');
|
||||
const list = fs.readFileSync('client/components/boards/boardsList.js', 'utf8');
|
||||
assert.match(server, /Meteor\.publish\('starredPublicBoards'/);
|
||||
assert.match(server, /Meteor\.publish\('starredPublicBoards', function\(boardIds\) \{\s*check\(boardIds, \[String\]\);\s*if \(!this\.userId\) return this\.ready\(\);/,
|
||||
'never for an anonymous subscriber - the instance boards it may include need a signed-in user');
|
||||
assert.match(server, /starredPublicBoardSelector\(boardIds\)/);
|
||||
assert.match(server, /starredPublicBoardSelector\(user\.profile\?\.starredBoards\)/);
|
||||
assert.match(header, /Tracker\.autorun\(\(\) => \{[\s\S]*?Meteor\.subscribe\('starredPublicBoards'/);
|
||||
|
||||
@@ -176,7 +176,12 @@ test('the publication chooses what it shows; the client does not', () => {
|
||||
const at = publications.indexOf('function publicBoardsSelector(');
|
||||
assert.notStrictEqual(at, -1, 'the selector must be built on the server');
|
||||
const sel = publications.slice(at, publications.indexOf('\n}', at));
|
||||
assert.ok(/permission: 'public'/.test(sel), 'public boards');
|
||||
// #3249: public boards for anybody; a signed-in visitor also sees 'instance'
|
||||
// boards, decided from the server's own this.userId.
|
||||
assert.ok(/permission: signedIn \? \{ \$in: \['public', 'instance'\] \} : 'public'/.test(sel), 'public boards');
|
||||
assert.ok(/function publicBoardsSelector\(searchTerm, signedIn = false\)/.test(publications), 'anonymous by default');
|
||||
assert.equal((publications.match(/publicBoardsSelector\(searchTerm, !!this\.userId\)/g) || []).length, 2,
|
||||
'the publication and its count both take who is signed in from the server');
|
||||
assert.ok(/archived: false/.test(sel), 'not archived');
|
||||
assert.ok(/type: 'board'/.test(sel), 'real boards, not template containers');
|
||||
assert.ok(/notHelperBoardTitle\(\)/.test(sel),
|
||||
|
||||
@@ -71,8 +71,14 @@ test('the public clause is optional, and only that clause', () => {
|
||||
assert.ok(withoutPublic.some(c => Object.prototype.hasOwnProperty.call(c, clause)),
|
||||
`${clause} is a real relationship and must always be in the scope`);
|
||||
}
|
||||
assert.strictEqual(withPublic.length - withoutPublic.length, 1,
|
||||
'dropping the public clause drops nothing else');
|
||||
// #3249: for a signed-in user the "readable without membership" clauses are
|
||||
// two - public and 'instance' - and includePublic: false drops both.
|
||||
assert.ok(withPublic.some(c => c.permission === 'instance'), 'instance boards for a signed-in user');
|
||||
assert.ok(!withoutPublic.some(c => c.permission === 'instance'), 'and they drop out with public');
|
||||
assert.ok(!boardVisibilitySelectors({ includePublic: true }).some(c => c.permission === 'instance'),
|
||||
'never for an anonymous caller');
|
||||
assert.strictEqual(withPublic.length - withoutPublic.length, 2,
|
||||
'dropping the public clauses drops nothing else');
|
||||
});
|
||||
|
||||
test('the option reaches userBoardIds, which is what the search calls', () => {
|
||||
@@ -111,7 +117,10 @@ test('the board: filter scopes the same way as the search around it', () => {
|
||||
test('userSearch keeps its old behaviour for anyone who does not ask', () => {
|
||||
const at = boards.indexOf('Boards.userSearch = (');
|
||||
const body = boards.slice(at, boards.indexOf('};', at));
|
||||
assert.ok(/options\.includePublic === false \? \[\] : \[\{ permission: 'public' \}\]/.test(body),
|
||||
// #3249: the "readable without membership" clauses come from
|
||||
// models/lib/boardPermission.js - public boards always, 'instance' boards for
|
||||
// a signed-in caller.
|
||||
assert.ok(/options\.includePublic === false \? \[\] : withoutMembershipSelectors\(!!userId\)/.test(body),
|
||||
'public boards are still included unless excluded explicitly');
|
||||
// An anonymous caller with public boards excluded can reach NOTHING. An empty
|
||||
// `$or` means "match nothing" in Mongo but is an error in some backends, so it
|
||||
|
||||
Reference in new issue
Block a user