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:
Lauri Ojansivu committed 2026-09-29 16:28:44 +03:00
1 parent 56cc45a6e0
commit f122a8287c
34 files changed
+424 -68

No files matched your search

+2 -1
View File
@@ -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')
);
},
+14 -1
View File
@@ -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
= " "
+2 -1
View File
@@ -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')
);
},
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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()) : [],
+3
View File
@@ -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.",
+3 -1
View File
@@ -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
View File
@@ -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 } } });
+38
View File
@@ -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 };
+11 -5
View File
@@ -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 };
+3 -2
View File
@@ -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 };
+6 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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"
}
]
}
+3 -1
View File
@@ -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
View File
@@ -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) {
+3 -2
View File
@@ -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',
+3 -1
View File
@@ -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;
+4 -2
View File
@@ -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;
}
+4 -2
View File
@@ -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({
+13 -10
View File
@@ -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');
+3 -1
View File
@@ -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);
+4 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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'],
+93
View File
@@ -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');
+20 -10
View File
@@ -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; });
+3 -1
View File
@@ -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');
+3 -2
View File
@@ -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 });
}
});
+7 -2
View File
@@ -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'/);
+6 -1
View File
@@ -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),
+12 -3
View File
@@ -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