mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 09:40:20 -05:00
Sandstorm: the client half runs again, and the Powerbox button adds people to the board.
sandstorm.js was imported only by server/imports.js since mainModule turned eager loading off, so its browser code never ran in a grain: the grain URL and title did not follow the board, the Powerbox picker could not open, and the isSandstorm helper was undefined, so email, password, logout and avatar settings that Sandstorm handles showed anyway. The server half - which loads the Cap'n Proto bridge client and node:net - is now server/sandstorm.js, and the client imports the shared sandstorm.js. Its shell listener ignores messages that are not its replies instead of throwing on them. The Powerbox button is back in the members widget, for people who may invite. It passes the open board, and the claimed person is invited to it through inviteUserToBoard, with the member picker's own permission checks; #2405 removed the old button because the identity it claimed was never attached to a board. Thanks to xet7 !
This commit is contained in:
1 parent
2e927f59f3
commit
ed5502edfa
12 files changed
+512
-353
No files matched your search
@@ -89,13 +89,18 @@ template(name="membersWidget")
|
||||
else
|
||||
each currentBoard.membersForSidebar
|
||||
+userAvatar(userId=this.userId showStatus=true)
|
||||
//- Sandstorm grains can contain multiple boards. Their users already
|
||||
exist in this grain after first visiting it, so use WeKan's normal,
|
||||
permission-checked board-member picker. The former Powerbox button's
|
||||
claimed identity was never attached to the selected board. #2405
|
||||
//- Sandstorm grains can contain multiple boards. Users who have already
|
||||
visited this grain are found by WeKan's normal, permission-checked
|
||||
board-member picker. The Powerbox button beside it picks a person
|
||||
through Sandstorm instead, and adds them to THIS board through the
|
||||
same inviteUserToBoard checks (#2405 removed it while the claimed
|
||||
identity was never attached to a board).
|
||||
if currentUser.canInviteToBoard
|
||||
a.member.add-member.js-manage-board-members(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
if isSandstorm
|
||||
a.member.add-member.sandstorm-powerbox-request-identity(title="{{_ 'invite-people'}}")
|
||||
i.fa.fa-user-plus
|
||||
.clearfix
|
||||
if isInvited
|
||||
hr
|
||||
|
||||
@@ -1175,7 +1175,7 @@ Template.membersWidget.events({
|
||||
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
|
||||
},
|
||||
'click .sandstorm-powerbox-request-identity'() {
|
||||
window.sandstormRequestIdentity();
|
||||
window.sandstormRequestIdentity(Session.get('currentBoard'));
|
||||
},
|
||||
'click .js-member-invite-accept'() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
|
||||
@@ -118,3 +118,8 @@ import '/client/components/main/structuralSelection';
|
||||
import '/client/components/main/boardDragSettings';
|
||||
|
||||
import './components/main/boardSettingsColumnActions';
|
||||
|
||||
// Sandstorm's shell bridge (grain URL and title, the Powerbox picker, the
|
||||
// relative Meteor.absoluteUrl) and the isSandstorm template helper. It does
|
||||
// nothing outside a grain.
|
||||
import '/sandstorm';
|
||||
@@ -68,6 +68,11 @@
|
||||
"https://docs\\.sandstorm\\.io/en/latest/developing/auth/",
|
||||
"https://docs\\.sandstorm\\.io/en/latest/developing/http\\-apis/",
|
||||
"https://keybase\\.io"
|
||||
],
|
||||
"server/sandstorm.js": [
|
||||
"http://127\\.0\\.0\\.1:4000",
|
||||
"https://github\\.com/sandstorm\\-io/meteor\\-accounts\\-sandstorm/pull/31",
|
||||
"https://github\\.com/wekan/wekan/issues/346"
|
||||
]
|
||||
},
|
||||
"files": {
|
||||
|
||||
+28
-327
@@ -1,23 +1,19 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
// Collections became ES module default exports in the Meteor 3.x migration (they
|
||||
// used to be implicit globals). This file only runs on Sandstorm, so it was missed
|
||||
// and kept referencing the old globals — which now throw "Users is not defined" at
|
||||
// server boot. Import them explicitly.
|
||||
import Users from '/models/users';
|
||||
import Activities from '/models/activities';
|
||||
import {
|
||||
claimUniqueUsername,
|
||||
usernameCaseInsensitiveRegex,
|
||||
} from '/models/lib/sandstormUsername';
|
||||
|
||||
// Sandstorm context is detected using the METEOR_SETTINGS environment variable
|
||||
// in the package definition.
|
||||
const isSandstorm = Meteor.settings?.public?.sandstorm;
|
||||
|
||||
// The server half - login headers, Powerbox identity claims, activity events -
|
||||
// is server/sandstorm.js. This file is imported by BOTH the client
|
||||
// (client/imports.js) and the server (server/imports.js): the code below that
|
||||
// talks to the Sandstorm shell, and the isSandstorm template helper, are
|
||||
// client code, and before this was imported by the client they never ran in a
|
||||
// browser at all.
|
||||
|
||||
// In sandstorm we only have one board per sandstorm instance. Since we want to
|
||||
// keep most of our code unchanged, we simply hard-code a board `_id` and
|
||||
// redirect the user to this particular board.
|
||||
@@ -35,316 +31,6 @@ const sandstormBoard = {
|
||||
permission: 'public',
|
||||
};
|
||||
|
||||
if (isSandstorm && Meteor.isServer) {
|
||||
const fs = require('fs');
|
||||
|
||||
// sandstorm-http-bridge historically advertises "Accept-Encoding: gzip" to the
|
||||
// app regardless of what the browser actually sent, so Meteor serves gzip/brotli
|
||||
// -encoded responses that the browser then fails to decode — the grain loads but
|
||||
// the page shows "Corrupted Content Error" (NS_ERROR_NET_CORRUPTED_CONTENT).
|
||||
// Strip Accept-Encoding on the way in so Meteor serves uncompressed responses;
|
||||
// rawHandlers runs before Meteor's static/boilerplate middleware, and bandwidth
|
||||
// is a non-issue behind the local bridge. (See Sandstorm CHANGELOG: "apps were
|
||||
// always being told Accept-Encoding: gzip whether or not the client sent it".)
|
||||
WebApp.rawHandlers.use((req, res, next) => {
|
||||
delete req.headers['accept-encoding'];
|
||||
next();
|
||||
});
|
||||
|
||||
// Rewrite ROOT_URL per request to the grain's ACTUAL URL. The launcher can only
|
||||
// set a fixed ROOT_URL (http://127.0.0.1:4000 — the internal bridge target), but
|
||||
// Sandstorm serves each grain at a per-session host (ui-<hash>.<host>) via
|
||||
// sandstorm-http-bridge. Without this, the client would send its DDP connection
|
||||
// and dynamic-import fetches to 127.0.0.1:4000 — cross-origin and unreachable from
|
||||
// the browser — so dynamic imports fail with CORS errors and the login handshake
|
||||
// never completes ("Must be logged in"). X-Sandstorm-Base-Path carries the grain's
|
||||
// real base URL, so point ROOT_URL at it (and re-assert the SANDSTORM flag that
|
||||
// triggers the client's header-based auto-login) in the runtime config sent to the
|
||||
// client, so it talks to the same origin it loaded from.
|
||||
WebApp.addRuntimeConfigHook(({ request, encodedCurrentConfig }) => {
|
||||
const base = request.headers['x-sandstorm-base-path'];
|
||||
if (!base) return undefined;
|
||||
const config = WebApp.decodeRuntimeConfig(encodedCurrentConfig);
|
||||
config.ROOT_URL = base.endsWith('/') ? base : base + '/';
|
||||
config.SANDSTORM = true;
|
||||
return WebApp.encodeRuntimeConfig(config);
|
||||
});
|
||||
|
||||
// WeKan on Sandstorm runs behind sandstorm-http-bridge. Login and user identity
|
||||
// come from the X-Sandstorm-* HTTP headers (see the wekan-accounts-sandstorm
|
||||
// package) and need NO Cap'n Proto. Two features do: Powerbox identity claims
|
||||
// (sandstormClaimIdentityRequest) and Sandstorm activity events
|
||||
// (Activities.after.insert -> reportActivity). Both are Cap'n Proto calls on the
|
||||
// bridge's /tmp/sandstorm-api socket - the bridge's HTTP proxy has no route for
|
||||
// either (sandstorm src/sandstorm/bridge-proxy.c++). They used node-capnp
|
||||
// (capnp.node), a native addon built for Node 14 that cannot load on Node 26, so
|
||||
// a grain ran without them. imports/sandstorm/bridgeClient.mjs now makes the same
|
||||
// calls in plain JavaScript with @wekanteam/capnp-es (npm-packages/capnp-es); it
|
||||
// is loaded on first use, so a non-Sandstorm server never loads it.
|
||||
let bridgeClientModule = null;
|
||||
let bridgeConnection = null;
|
||||
async function bridgeClient() {
|
||||
if (!bridgeClientModule) {
|
||||
bridgeClientModule = await import('/imports/sandstorm/bridgeClient.mjs');
|
||||
bridgeConnection = bridgeClientModule.createBridge();
|
||||
}
|
||||
return { api: bridgeClientModule, bridge: bridgeConnection.bridge };
|
||||
}
|
||||
|
||||
// An activity type is sent as its index in the pkgdef's
|
||||
// bridgeConfig.viewInfo.eventTypes, which Sandstorm puts in every grain as
|
||||
// /sandstorm-http-bridge-config.
|
||||
let eventTypeNames = null;
|
||||
function activityTypeIndex(api, name) {
|
||||
if (!eventTypeNames) {
|
||||
eventTypeNames = api.parseEventTypeNames(fs.readFileSync('/sandstorm-http-bridge-config'));
|
||||
}
|
||||
return eventTypeNames.indexOf(name);
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
async sandstormClaimIdentityRequest(token, descriptor) {
|
||||
check(token, String);
|
||||
check(descriptor, String);
|
||||
|
||||
const sessionId = this.connection.sandstormSessionId();
|
||||
const { api, bridge } = await bridgeClient();
|
||||
const permissions = api.parseIdentityRequestPermissions(descriptor);
|
||||
const sandstormInfo = await api.claimIdentity(bridge, sessionId, token, permissions);
|
||||
|
||||
const login = await Accounts.updateOrCreateUserFromExternalService(
|
||||
'sandstorm',
|
||||
sandstormInfo,
|
||||
{ profile: { name: sandstormInfo.name } },
|
||||
);
|
||||
|
||||
await updateUserPermissions(login.userId, permissions);
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.startup(() => {
|
||||
Activities.after.insert(async (userId, doc) => {
|
||||
// HACK: We need the connection that's making the request in order to read the
|
||||
// Sandstorm session ID. Read it before the first await: the invocation is
|
||||
// only current until then.
|
||||
const invocation = DDP._CurrentInvocation.get(); // eslint-disable-line no-undef
|
||||
if (!invocation) return;
|
||||
const sessionId = invocation.connection.sandstormSessionId();
|
||||
|
||||
try {
|
||||
const { api, bridge } = await bridgeClient();
|
||||
const defIdx = activityTypeIndex(api, doc.activityType);
|
||||
if (defIdx < 0) return;
|
||||
|
||||
const users = {};
|
||||
async function ensureUserListed(userId) {
|
||||
if (!users[userId]) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user && user.services && user.services.sandstorm) {
|
||||
users[userId] = { id: user.services.sandstorm.id };
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function mentionedUser(userId) {
|
||||
if (await ensureUserListed(userId)) {
|
||||
users[userId].mentioned = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribedUser(userId) {
|
||||
if (await ensureUserListed(userId)) {
|
||||
users[userId].subscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let path = '';
|
||||
let caption = null;
|
||||
|
||||
if (doc.cardId) {
|
||||
path = `b/sandstorm/libreboard/${doc.cardId}`;
|
||||
const card = ReactiveCache.getCard(doc.cardId);
|
||||
if (card && card.members) {
|
||||
for (const memberId of card.members) {
|
||||
await subscribedUser(memberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.memberId) {
|
||||
await mentionedUser(doc.memberId);
|
||||
}
|
||||
|
||||
if (doc.activityType === 'addComment') {
|
||||
const comment = ReactiveCache.getCardComment(doc.commentId);
|
||||
caption = comment.text;
|
||||
const board = ReactiveCache.getBoard(doc.boardId);
|
||||
const activeMembers = board ? board.activeMembers().map(x => x.userId) : [];
|
||||
const mentions = comment.text.match(/\B@([\w.]*)/g) || [];
|
||||
for (const username of mentions) {
|
||||
const user = await Meteor.users.findOneAsync({
|
||||
username: username.slice(1),
|
||||
});
|
||||
if (user && activeMembers.indexOf(user._id) !== -1) {
|
||||
await mentionedUser(user._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.reportActivity(bridge, sessionId, {
|
||||
path,
|
||||
type: defIdx,
|
||||
users: Object.values(users),
|
||||
caption,
|
||||
});
|
||||
} catch (e) {
|
||||
// A notification that could not be sent must not fail the change that
|
||||
// caused it.
|
||||
console.error('** WeKan/Sandstorm: activity event not sent:', (e && e.message) || e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function updateUserPermissions(userId, permissions) {
|
||||
// Multi-board Sandstorm grain: there is no single hard-coded board to add the
|
||||
// user to, so map the grain's Sandstorm permissions to the user's GLOBAL WeKan
|
||||
// role instead. 'configure' (the grain owner / admin) becomes a WeKan admin;
|
||||
// everyone else is a regular user who can create and manage their own boards.
|
||||
// (Per-board membership is handled inside WeKan when a user creates/shares a
|
||||
// board.)
|
||||
const isAdmin = permissions.indexOf('configure') > -1;
|
||||
await Users.updateAsync(userId, { $set: { isAdmin } });
|
||||
}
|
||||
|
||||
// NOTE: there used to be a WebApp.handlers.get('/') here that redirected the grain
|
||||
// root to a single hard-coded board ('/b/sandstorm/libreboard'). WeKan on Sandstorm
|
||||
// now supports MANY boards and no longer auto-creates that libreboard, so a
|
||||
// redirect to it is wrong. Removing the handler lets '/' fall through to WeKan's
|
||||
// normal serving, whose client route '/' ('home' in config/router.js) renders the
|
||||
// All Boards list — exactly what a multi-board grain should open on. (It also
|
||||
// avoids the Meteor-3.x server-side redirect bug: FlowRouter.path() does not
|
||||
// resolve on the server, and a bare redirect with a mismatched Content-Length made
|
||||
// the browser show a "Corrupted Content Error".)
|
||||
|
||||
// On the first launch of the instance a user is automatically created thanks
|
||||
// to the `accounts-sandstorm` package. Note that when the `Users.after.insert`
|
||||
// hook is called, the user is inserted into the database but not connected. So
|
||||
// despite the appearances `userId` is null in this block.
|
||||
Users.after.insert(async (userId, doc) => {
|
||||
// Only Sandstorm-authenticated users carry services.sandstorm; this hook derives
|
||||
// the username/fullname/avatar/permissions from it. Imported placeholder members
|
||||
// (and any other non-Sandstorm insert) have no services.sandstorm, so skip them —
|
||||
// otherwise reading doc.services.sandstorm.preferredHandle throws and aborts the
|
||||
// insert (e.g. when importing a board full of original members into a grain).
|
||||
if (!doc || !doc.services || !doc.services.sandstorm) return;
|
||||
|
||||
// No board is auto-created for a new grain/user: WeKan on Sandstorm is
|
||||
// multi-board now, and the user creates their own boards from the All Boards
|
||||
// page. (Previously a single hard-coded 'sandstorm'/libreboard board was
|
||||
// inserted here with a Default swimlane.)
|
||||
|
||||
// We rely on username uniqueness for the user mention feature, but
|
||||
// Sandstorm doesn't enforce this property -- see #352. Our strategy to
|
||||
// generate unique usernames from the Sandstorm `preferredHandle` is to
|
||||
// append a number that we increment until we generate a username that no
|
||||
// one already uses (eg, 'max', 'max1', 'max2'). Meteor usernames are
|
||||
// case-preserving but case-insensitively unique, so the probe uses an
|
||||
// anchored case-insensitive regex (escaped, see #574), and the update is
|
||||
// retried with the next number when a concurrent insert wins the race to
|
||||
// the same name (unique-index duplicate-key error).
|
||||
const username = doc.services.sandstorm.preferredHandle;
|
||||
await claimUniqueUsername(
|
||||
username,
|
||||
async candidate =>
|
||||
!!(await Meteor.users.findOneAsync({
|
||||
_id: { $ne: doc._id },
|
||||
username: usernameCaseInsensitiveRegex(candidate),
|
||||
})),
|
||||
candidate =>
|
||||
Users.updateAsync(doc._id, {
|
||||
$set: {
|
||||
username: candidate,
|
||||
'profile.fullname': doc.services.sandstorm.name,
|
||||
'profile.avatarUrl': doc.services.sandstorm.picture,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await updateUserPermissions(doc._id, doc.services.sandstorm.permissions);
|
||||
});
|
||||
|
||||
Meteor.startup(async () => {
|
||||
// Migrated grains: a user imported from an older WeKan Sandstorm grain already
|
||||
// exists, so `Users.after.insert` never derives their WeKan admin role, and the
|
||||
// observeChanges hook below only re-derives isAdmin when services.sandstorm
|
||||
// actually CHANGES on login. Sandstorm auto-login uses connection.setUserId(),
|
||||
// which bypasses accounts-base (no Accounts.onLogin fires); and when a migrated
|
||||
// user's stored services.sandstorm.permissions already equals the grain's
|
||||
// current permissions, the login `$set` is a no-op that produces no oplog entry,
|
||||
// so the change hook never fires. Old WeKan on Sandstorm had no global Admin
|
||||
// Panel, so such users have no isAdmin flag and the grain owner cannot reach
|
||||
// Admin Panel / Attachments / Sandstorm to delete leftover files. Reconcile at
|
||||
// boot (a migrated grain reboots once migration completes): grant WeKan admin to
|
||||
// every Sandstorm user whose stored permissions include the grain-owner
|
||||
// 'configure' capability. Promote only — never revoke admin here; that is the
|
||||
// change hook's job on a real Sandstorm permission change, so a stale/empty
|
||||
// stored permissions set can never lock the owner out of the Admin Panel.
|
||||
const sandstormUsers = await Users.find(
|
||||
{ 'services.sandstorm.permissions': 'configure' },
|
||||
{ fields: { isAdmin: 1 } },
|
||||
).fetchAsync();
|
||||
for (const user of sandstormUsers) {
|
||||
if (!user.isAdmin) {
|
||||
await Users.updateAsync(user._id, { $set: { isAdmin: true } });
|
||||
}
|
||||
}
|
||||
|
||||
await Users.find().observeChangesAsync({
|
||||
async changed(userId, fields) {
|
||||
const sandstormData = (fields.services || {}).sandstorm || {};
|
||||
if (sandstormData.name) {
|
||||
await Users.updateAsync(userId, {
|
||||
$set: { 'profile.fullname': sandstormData.name },
|
||||
});
|
||||
}
|
||||
|
||||
if (sandstormData.picture) {
|
||||
await Users.updateAsync(userId, {
|
||||
$set: { 'profile.avatarUrl': sandstormData.picture },
|
||||
});
|
||||
}
|
||||
|
||||
if (sandstormData.permissions) {
|
||||
await updateUserPermissions(userId, sandstormData.permissions);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Wekan v0.8 didn’t implement the Sandstorm sharing model and instead kept
|
||||
// the visibility setting (“public” or “private”) in the UI as does the main
|
||||
// Meteor application. We need to enforce “public” visibility as the sharing
|
||||
// is now handled by Sandstorm.
|
||||
// See https://github.com/wekan/wekan/issues/346
|
||||
// Migration disabled - using backward compatibility approach
|
||||
/*
|
||||
Migrations.add('enforce-public-visibility-for-sandstorm', () => {
|
||||
Boards.update('sandstorm', { $set: { permission: 'public' } });
|
||||
});
|
||||
*/
|
||||
|
||||
// NOTE: a legacy monkey patch of `HTTP.methods` (to inject Sandstorm auth into
|
||||
// the old meteor/http server methods, working around
|
||||
// https://github.com/sandstorm-io/meteor-accounts-sandstorm/pull/31) used to live
|
||||
// here. The `meteor/http` package was removed in the Meteor 3.x migration, so
|
||||
// `HTTP` is now undefined and the patch crashed the Sandstorm server at boot with
|
||||
// "HTTP is not defined". WeKan's REST API no longer uses HTTP.methods (it uses
|
||||
// WebApp/JsonRoutes with header-based auth), so the patch is obsolete and removed.
|
||||
}
|
||||
|
||||
if (isSandstorm && Meteor.isClient) {
|
||||
let rpcCounter = 0;
|
||||
const rpcs = {};
|
||||
@@ -355,17 +41,21 @@ if (isSandstorm && Meteor.isClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the Sandstorm shell's replies to our RPCs are ours. Other frames and
|
||||
// browser extensions post messages too; throwing on them (as this used to)
|
||||
// was harmless only while this file never ran in a browser.
|
||||
if (
|
||||
event.source !== window.parent ||
|
||||
!event.data ||
|
||||
typeof event.data !== 'object' ||
|
||||
typeof event.data.rpcId !== 'number'
|
||||
) {
|
||||
throw new Error(`got unexpected postMessage: ${event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = rpcs[event.data.rpcId];
|
||||
if (!handler) {
|
||||
throw new Error(`no such rpc ID for event ${event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
delete rpcs[event.data.rpcId];
|
||||
@@ -413,12 +103,23 @@ if (isSandstorm && Meteor.isClient) {
|
||||
});
|
||||
}
|
||||
|
||||
window.sandstormRequestIdentity = function() {
|
||||
doRequest(powerboxDescriptors.identity, response => {
|
||||
// Opens the Sandstorm Powerbox to pick a person, then makes them a member of
|
||||
// boardId (the sidebar's members widget passes the open board): the server
|
||||
// redeems the request and invites them through inviteUserToBoard, with the
|
||||
// same permission checks as WeKan's own member picker.
|
||||
window.sandstormRequestIdentity = function(boardId) {
|
||||
return doRequest(powerboxDescriptors.identity, response => {
|
||||
Meteor.call(
|
||||
'sandstormClaimIdentityRequest',
|
||||
response.token,
|
||||
response.descriptor,
|
||||
boardId,
|
||||
error => {
|
||||
if (error) {
|
||||
console.error('Sandstorm Powerbox: could not add the person:', error);
|
||||
window.alert(error.reason || error.message || String(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -424,6 +424,7 @@ import '/server/importedUserReconciliation';
|
||||
// 17. Sandstorm integration
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/sandstorm';
|
||||
import '/server/sandstorm';
|
||||
|
||||
import '/server/methods/fileStatusAudit';
|
||||
import '/server/methods/instrumentationReport';
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// WeKan on Sandstorm - the server half. sandstorm.js holds what the client
|
||||
// needs as well (the grain-shell postMessage bridge, the isSandstorm helper);
|
||||
// this file is server-only so that the client bundle never sees the Cap'n Proto
|
||||
// bridge client it loads, which needs node:net.
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { check, Match } from 'meteor/check';
|
||||
// Collections became ES module default exports in the Meteor 3.x migration (they
|
||||
// used to be implicit globals). This file only runs on Sandstorm, so it was missed
|
||||
// and kept referencing the old globals — which now throw "Users is not defined" at
|
||||
// server boot. Import them explicitly.
|
||||
import Users from '/models/users';
|
||||
import Activities from '/models/activities';
|
||||
import {
|
||||
claimUniqueUsername,
|
||||
usernameCaseInsensitiveRegex,
|
||||
} from '/models/lib/sandstormUsername';
|
||||
|
||||
// Sandstorm context is detected using the METEOR_SETTINGS environment variable
|
||||
// in the package definition.
|
||||
const isSandstorm = Meteor.settings?.public?.sandstorm;
|
||||
|
||||
if (isSandstorm) {
|
||||
const fs = require('fs');
|
||||
|
||||
// sandstorm-http-bridge historically advertises "Accept-Encoding: gzip" to the
|
||||
// app regardless of what the browser actually sent, so Meteor serves gzip/brotli
|
||||
// -encoded responses that the browser then fails to decode — the grain loads but
|
||||
// the page shows "Corrupted Content Error" (NS_ERROR_NET_CORRUPTED_CONTENT).
|
||||
// Strip Accept-Encoding on the way in so Meteor serves uncompressed responses;
|
||||
// rawHandlers runs before Meteor's static/boilerplate middleware, and bandwidth
|
||||
// is a non-issue behind the local bridge. (See Sandstorm CHANGELOG: "apps were
|
||||
// always being told Accept-Encoding: gzip whether or not the client sent it".)
|
||||
WebApp.rawHandlers.use((req, res, next) => {
|
||||
delete req.headers['accept-encoding'];
|
||||
next();
|
||||
});
|
||||
|
||||
// Rewrite ROOT_URL per request to the grain's ACTUAL URL. The launcher can only
|
||||
// set a fixed ROOT_URL (http://127.0.0.1:4000 — the internal bridge target), but
|
||||
// Sandstorm serves each grain at a per-session host (ui-<hash>.<host>) via
|
||||
// sandstorm-http-bridge. Without this, the client would send its DDP connection
|
||||
// and dynamic-import fetches to 127.0.0.1:4000 — cross-origin and unreachable from
|
||||
// the browser — so dynamic imports fail with CORS errors and the login handshake
|
||||
// never completes ("Must be logged in"). X-Sandstorm-Base-Path carries the grain's
|
||||
// real base URL, so point ROOT_URL at it (and re-assert the SANDSTORM flag that
|
||||
// triggers the client's header-based auto-login) in the runtime config sent to the
|
||||
// client, so it talks to the same origin it loaded from.
|
||||
WebApp.addRuntimeConfigHook(({ request, encodedCurrentConfig }) => {
|
||||
const base = request.headers['x-sandstorm-base-path'];
|
||||
if (!base) return undefined;
|
||||
const config = WebApp.decodeRuntimeConfig(encodedCurrentConfig);
|
||||
config.ROOT_URL = base.endsWith('/') ? base : base + '/';
|
||||
config.SANDSTORM = true;
|
||||
return WebApp.encodeRuntimeConfig(config);
|
||||
});
|
||||
|
||||
// WeKan on Sandstorm runs behind sandstorm-http-bridge. Login and user identity
|
||||
// come from the X-Sandstorm-* HTTP headers (see the wekan-accounts-sandstorm
|
||||
// package) and need NO Cap'n Proto. Two features do: Powerbox identity claims
|
||||
// (sandstormClaimIdentityRequest) and Sandstorm activity events
|
||||
// (Activities.after.insert -> reportActivity). Both are Cap'n Proto calls on the
|
||||
// bridge's /tmp/sandstorm-api socket - the bridge's HTTP proxy has no route for
|
||||
// either (sandstorm src/sandstorm/bridge-proxy.c++). They used node-capnp
|
||||
// (capnp.node), a native addon built for Node 14 that cannot load on Node 26, so
|
||||
// a grain ran without them. imports/sandstorm/bridgeClient.mjs now makes the same
|
||||
// calls in plain JavaScript with @wekanteam/capnp-es (npm-packages/capnp-es); it
|
||||
// is loaded on first use, so a non-Sandstorm server never loads it.
|
||||
let bridgeClientModule = null;
|
||||
let bridgeConnection = null;
|
||||
async function bridgeClient() {
|
||||
if (!bridgeClientModule) {
|
||||
bridgeClientModule = await import('/imports/sandstorm/bridgeClient.mjs');
|
||||
bridgeConnection = bridgeClientModule.createBridge();
|
||||
}
|
||||
return { api: bridgeClientModule, bridge: bridgeConnection.bridge };
|
||||
}
|
||||
|
||||
// An activity type is sent as its index in the pkgdef's
|
||||
// bridgeConfig.viewInfo.eventTypes, which Sandstorm puts in every grain as
|
||||
// /sandstorm-http-bridge-config.
|
||||
let eventTypeNames = null;
|
||||
function activityTypeIndex(api, name) {
|
||||
if (!eventTypeNames) {
|
||||
eventTypeNames = api.parseEventTypeNames(fs.readFileSync('/sandstorm-http-bridge-config'));
|
||||
}
|
||||
return eventTypeNames.indexOf(name);
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
// The sidebar's Powerbox button: redeem the person the user picked, and when
|
||||
// a board is given, invite them to it exactly as WeKan's own member picker
|
||||
// does - inviteUserToBoard, run as the caller, applies board membership, the
|
||||
// Admin Panel's invite roles and the organization/team restrictions.
|
||||
async sandstormClaimIdentityRequest(token, descriptor, boardId) {
|
||||
check(token, String);
|
||||
check(descriptor, String);
|
||||
check(boardId, Match.Maybe(String));
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
|
||||
const sessionId = this.connection.sandstormSessionId();
|
||||
const { api, bridge } = await bridgeClient();
|
||||
const permissions = api.parseIdentityRequestPermissions(descriptor);
|
||||
const sandstormInfo = await api.claimIdentity(bridge, sessionId, token, permissions);
|
||||
|
||||
const login = await Accounts.updateOrCreateUserFromExternalService(
|
||||
'sandstorm',
|
||||
sandstormInfo,
|
||||
{ profile: { name: sandstormInfo.name } },
|
||||
);
|
||||
|
||||
await updateUserPermissions(login.userId, permissions);
|
||||
|
||||
if (boardId) {
|
||||
await Meteor.callAsync('inviteUserToBoard', login.userId, boardId);
|
||||
}
|
||||
return { userId: login.userId };
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.startup(() => {
|
||||
Activities.after.insert(async (userId, doc) => {
|
||||
// HACK: We need the connection that's making the request in order to read the
|
||||
// Sandstorm session ID. Read it before the first await: the invocation is
|
||||
// only current until then.
|
||||
const invocation = DDP._CurrentInvocation.get(); // eslint-disable-line no-undef
|
||||
if (!invocation) return;
|
||||
const sessionId = invocation.connection.sandstormSessionId();
|
||||
|
||||
try {
|
||||
const { api, bridge } = await bridgeClient();
|
||||
const defIdx = activityTypeIndex(api, doc.activityType);
|
||||
if (defIdx < 0) return;
|
||||
|
||||
const users = {};
|
||||
async function ensureUserListed(userId) {
|
||||
if (!users[userId]) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user && user.services && user.services.sandstorm) {
|
||||
users[userId] = { id: user.services.sandstorm.id };
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function mentionedUser(userId) {
|
||||
if (await ensureUserListed(userId)) {
|
||||
users[userId].mentioned = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribedUser(userId) {
|
||||
if (await ensureUserListed(userId)) {
|
||||
users[userId].subscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let path = '';
|
||||
let caption = null;
|
||||
|
||||
if (doc.cardId) {
|
||||
path = `b/sandstorm/libreboard/${doc.cardId}`;
|
||||
const card = ReactiveCache.getCard(doc.cardId);
|
||||
if (card && card.members) {
|
||||
for (const memberId of card.members) {
|
||||
await subscribedUser(memberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.memberId) {
|
||||
await mentionedUser(doc.memberId);
|
||||
}
|
||||
|
||||
if (doc.activityType === 'addComment') {
|
||||
const comment = ReactiveCache.getCardComment(doc.commentId);
|
||||
caption = comment.text;
|
||||
const board = ReactiveCache.getBoard(doc.boardId);
|
||||
const activeMembers = board ? board.activeMembers().map(x => x.userId) : [];
|
||||
const mentions = comment.text.match(/\B@([\w.]*)/g) || [];
|
||||
for (const username of mentions) {
|
||||
const user = await Meteor.users.findOneAsync({
|
||||
username: username.slice(1),
|
||||
});
|
||||
if (user && activeMembers.indexOf(user._id) !== -1) {
|
||||
await mentionedUser(user._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.reportActivity(bridge, sessionId, {
|
||||
path,
|
||||
type: defIdx,
|
||||
users: Object.values(users),
|
||||
caption,
|
||||
});
|
||||
} catch (e) {
|
||||
// A notification that could not be sent must not fail the change that
|
||||
// caused it.
|
||||
console.error('** WeKan/Sandstorm: activity event not sent:', (e && e.message) || e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function updateUserPermissions(userId, permissions) {
|
||||
// Multi-board Sandstorm grain: there is no single hard-coded board to add the
|
||||
// user to, so map the grain's Sandstorm permissions to the user's GLOBAL WeKan
|
||||
// role instead. 'configure' (the grain owner / admin) becomes a WeKan admin;
|
||||
// everyone else is a regular user who can create and manage their own boards.
|
||||
// (Per-board membership is handled inside WeKan when a user creates/shares a
|
||||
// board.)
|
||||
const isAdmin = permissions.indexOf('configure') > -1;
|
||||
await Users.updateAsync(userId, { $set: { isAdmin } });
|
||||
}
|
||||
|
||||
// NOTE: there used to be a WebApp.handlers.get('/') here that redirected the grain
|
||||
// root to a single hard-coded board ('/b/sandstorm/libreboard'). WeKan on Sandstorm
|
||||
// now supports MANY boards and no longer auto-creates that libreboard, so a
|
||||
// redirect to it is wrong. Removing the handler lets '/' fall through to WeKan's
|
||||
// normal serving, whose client route '/' ('home' in config/router.js) renders the
|
||||
// All Boards list — exactly what a multi-board grain should open on. (It also
|
||||
// avoids the Meteor-3.x server-side redirect bug: FlowRouter.path() does not
|
||||
// resolve on the server, and a bare redirect with a mismatched Content-Length made
|
||||
// the browser show a "Corrupted Content Error".)
|
||||
|
||||
// On the first launch of the instance a user is automatically created thanks
|
||||
// to the `accounts-sandstorm` package. Note that when the `Users.after.insert`
|
||||
// hook is called, the user is inserted into the database but not connected. So
|
||||
// despite the appearances `userId` is null in this block.
|
||||
Users.after.insert(async (userId, doc) => {
|
||||
// Only Sandstorm-authenticated users carry services.sandstorm; this hook derives
|
||||
// the username/fullname/avatar/permissions from it. Imported placeholder members
|
||||
// (and any other non-Sandstorm insert) have no services.sandstorm, so skip them —
|
||||
// otherwise reading doc.services.sandstorm.preferredHandle throws and aborts the
|
||||
// insert (e.g. when importing a board full of original members into a grain).
|
||||
if (!doc || !doc.services || !doc.services.sandstorm) return;
|
||||
|
||||
// No board is auto-created for a new grain/user: WeKan on Sandstorm is
|
||||
// multi-board now, and the user creates their own boards from the All Boards
|
||||
// page. (Previously a single hard-coded 'sandstorm'/libreboard board was
|
||||
// inserted here with a Default swimlane.)
|
||||
|
||||
// We rely on username uniqueness for the user mention feature, but
|
||||
// Sandstorm doesn't enforce this property -- see #352. Our strategy to
|
||||
// generate unique usernames from the Sandstorm `preferredHandle` is to
|
||||
// append a number that we increment until we generate a username that no
|
||||
// one already uses (eg, 'max', 'max1', 'max2'). Meteor usernames are
|
||||
// case-preserving but case-insensitively unique, so the probe uses an
|
||||
// anchored case-insensitive regex (escaped, see #574), and the update is
|
||||
// retried with the next number when a concurrent insert wins the race to
|
||||
// the same name (unique-index duplicate-key error).
|
||||
const username = doc.services.sandstorm.preferredHandle;
|
||||
await claimUniqueUsername(
|
||||
username,
|
||||
async candidate =>
|
||||
!!(await Meteor.users.findOneAsync({
|
||||
_id: { $ne: doc._id },
|
||||
username: usernameCaseInsensitiveRegex(candidate),
|
||||
})),
|
||||
candidate =>
|
||||
Users.updateAsync(doc._id, {
|
||||
$set: {
|
||||
username: candidate,
|
||||
'profile.fullname': doc.services.sandstorm.name,
|
||||
'profile.avatarUrl': doc.services.sandstorm.picture,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await updateUserPermissions(doc._id, doc.services.sandstorm.permissions);
|
||||
});
|
||||
|
||||
Meteor.startup(async () => {
|
||||
// Migrated grains: a user imported from an older WeKan Sandstorm grain already
|
||||
// exists, so `Users.after.insert` never derives their WeKan admin role, and the
|
||||
// observeChanges hook below only re-derives isAdmin when services.sandstorm
|
||||
// actually CHANGES on login. Sandstorm auto-login uses connection.setUserId(),
|
||||
// which bypasses accounts-base (no Accounts.onLogin fires); and when a migrated
|
||||
// user's stored services.sandstorm.permissions already equals the grain's
|
||||
// current permissions, the login `$set` is a no-op that produces no oplog entry,
|
||||
// so the change hook never fires. Old WeKan on Sandstorm had no global Admin
|
||||
// Panel, so such users have no isAdmin flag and the grain owner cannot reach
|
||||
// Admin Panel / Attachments / Sandstorm to delete leftover files. Reconcile at
|
||||
// boot (a migrated grain reboots once migration completes): grant WeKan admin to
|
||||
// every Sandstorm user whose stored permissions include the grain-owner
|
||||
// 'configure' capability. Promote only — never revoke admin here; that is the
|
||||
// change hook's job on a real Sandstorm permission change, so a stale/empty
|
||||
// stored permissions set can never lock the owner out of the Admin Panel.
|
||||
const sandstormUsers = await Users.find(
|
||||
{ 'services.sandstorm.permissions': 'configure' },
|
||||
{ fields: { isAdmin: 1 } },
|
||||
).fetchAsync();
|
||||
for (const user of sandstormUsers) {
|
||||
if (!user.isAdmin) {
|
||||
await Users.updateAsync(user._id, { $set: { isAdmin: true } });
|
||||
}
|
||||
}
|
||||
|
||||
await Users.find().observeChangesAsync({
|
||||
async changed(userId, fields) {
|
||||
const sandstormData = (fields.services || {}).sandstorm || {};
|
||||
if (sandstormData.name) {
|
||||
await Users.updateAsync(userId, {
|
||||
$set: { 'profile.fullname': sandstormData.name },
|
||||
});
|
||||
}
|
||||
|
||||
if (sandstormData.picture) {
|
||||
await Users.updateAsync(userId, {
|
||||
$set: { 'profile.avatarUrl': sandstormData.picture },
|
||||
});
|
||||
}
|
||||
|
||||
if (sandstormData.permissions) {
|
||||
await updateUserPermissions(userId, sandstormData.permissions);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Wekan v0.8 didn’t implement the Sandstorm sharing model and instead kept
|
||||
// the visibility setting (“public” or “private”) in the UI as does the main
|
||||
// Meteor application. We need to enforce “public” visibility as the sharing
|
||||
// is now handled by Sandstorm.
|
||||
// See https://github.com/wekan/wekan/issues/346
|
||||
// Migration disabled - using backward compatibility approach
|
||||
/*
|
||||
Migrations.add('enforce-public-visibility-for-sandstorm', () => {
|
||||
Boards.update('sandstorm', { $set: { permission: 'public' } });
|
||||
});
|
||||
*/
|
||||
|
||||
// NOTE: a legacy monkey patch of `HTTP.methods` (to inject Sandstorm auth into
|
||||
// the old meteor/http server methods, working around
|
||||
// https://github.com/sandstorm-io/meteor-accounts-sandstorm/pull/31) used to live
|
||||
// here. The `meteor/http` package was removed in the Meteor 3.x migration, so
|
||||
// `HTTP` is now undefined and the patch crashed the Sandstorm server at boot with
|
||||
// "HTTP is not defined". WeKan's REST API no longer uses HTTP.methods (it uses
|
||||
// WebApp/JsonRoutes with header-based auth), so the patch is obsolete and removed.
|
||||
}
|
||||
@@ -479,8 +479,8 @@ async function main() {
|
||||
assert.deepStrictEqual(client.parseIdentityRequestPermissions(descriptor), ['participate']);
|
||||
});
|
||||
|
||||
await test('sandstorm.js uses the JavaScript bridge client, and nothing loads capnp.node (negative)', async () => {
|
||||
const js = read('sandstorm.js');
|
||||
await test('server/sandstorm.js uses the JavaScript bridge client, and nothing loads capnp.node (negative)', async () => {
|
||||
const js = read('server/sandstorm.js');
|
||||
assert.ok(js.includes("import('/imports/sandstorm/bridgeClient.mjs')"));
|
||||
assert.ok(/reportActivity\(/.test(js) && /claimIdentity\(/.test(js));
|
||||
const offenders = spawnSync('git', ['grep', '-l', '-E', "Npm\\.require\\(['\"]capnp['\"]\\)|require\\(['\"]capnp['\"]\\)",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
'use strict';
|
||||
|
||||
// sandstorm.js holds the code a grain needs IN THE BROWSER: the postMessage
|
||||
// bridge to the Sandstorm shell (the grain URL and title follow the board, the
|
||||
// Powerbox picker opens), the relative Meteor.absoluteUrl, and the isSandstorm
|
||||
// template helper that hides what Sandstorm handles itself (email, password,
|
||||
// logout, avatar upload). Since `mainModule` turned eager loading off, only
|
||||
// server/imports.js imported it, so none of that ever ran in a browser. It is
|
||||
// now split: sandstorm.js is shared and imported by the client too, and the
|
||||
// server half - which loads the Cap'n Proto bridge client, and with it node:net
|
||||
// - is server/sandstorm.js, which the client bundle must never reach.
|
||||
// Run: node tests/sandstormClientHalf.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
const shared = read('sandstorm.js');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
console.log('sandstormClientHalf:');
|
||||
|
||||
test('the client loads the shared half, the server loads both', () => {
|
||||
assert.match(read('client/imports.js'), /^import '\/sandstorm';$/m);
|
||||
const server = read('server/imports.js');
|
||||
assert.match(server, /^import '\/sandstorm';$/m);
|
||||
assert.match(server, /^import '\/server\/sandstorm';$/m);
|
||||
});
|
||||
|
||||
test('the shared half carries nothing that only runs on a server (negative)', () => {
|
||||
for (const [pattern, why] of [
|
||||
[/meteor\/webapp/, 'WebApp handlers'],
|
||||
[/require\(['"]fs['"]\)|from ['"](node:)?fs['"]/, 'the filesystem'],
|
||||
[/bridgeClient/, 'the Cap\'n Proto client and node:net'],
|
||||
[/from ['"]node:/, 'a Node built-in'],
|
||||
[/Meteor\.isServer/, 'a server block'],
|
||||
[/Meteor\.methods\(/, 'server methods'],
|
||||
]) {
|
||||
assert.doesNotMatch(shared, pattern, `sandstorm.js must not contain ${why}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no client code imports the server half (negative)', () => {
|
||||
const hits = spawnSync('git', ['grep', '-l', '-E', "['\"]/server/sandstorm['\"]", '--', 'client', 'imports', 'models', 'config', 'sandstorm.js'],
|
||||
{ cwd: ROOT, encoding: 'utf8' }).stdout.trim();
|
||||
assert.strictEqual(hits, '', hits);
|
||||
});
|
||||
|
||||
test('the shell bridge ignores messages that are not its replies', () => {
|
||||
assert.doesNotMatch(shared, /throw new Error\(`got unexpected postMessage/,
|
||||
'other frames and extensions post messages too; throwing on them broke the page');
|
||||
assert.doesNotMatch(shared, /throw new Error\(`no such rpc ID/);
|
||||
});
|
||||
|
||||
test('the grain URL, title and isSandstorm helper are set up on the client', () => {
|
||||
const client = shared.slice(shared.indexOf('if (isSandstorm && Meteor.isClient) {'));
|
||||
assert.ok(client.length > 0, 'a client block');
|
||||
assert.match(client, /setPath:/);
|
||||
assert.match(client, /setTitle: document\.title/);
|
||||
assert.match(client, /window\.sandstormRequestIdentity = function\(boardId\)/);
|
||||
assert.match(shared, /^Blaze\.registerHelper\('isSandstorm', isSandstorm\);$/m);
|
||||
assert.match(shared, /^import \{ Blaze \} from 'meteor\/blaze';$/m);
|
||||
assert.match(shared, /^import \{ Tracker \} from 'meteor\/tracker';$/m);
|
||||
});
|
||||
|
||||
console.log(`\nsandstormClientHalf: ${passed} tests passed`);
|
||||
@@ -1,10 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
// Regression coverage for #2405, #2428 and #3684. A multi-board Sandstorm
|
||||
// grain must use the maintained board-member workflow; its old Powerbox-only
|
||||
// control cannot attach the identity it claims to the board. (Claiming itself
|
||||
// works again, through imports/sandstorm/bridgeClient.mjs - see
|
||||
// tests/sandstormCapnpRpc.test.cjs.)
|
||||
// Regression coverage for #2405, #2428 and #3684. A multi-board Sandstorm grain
|
||||
// keeps WeKan's normal, permission-checked board-member picker. Beside it, the
|
||||
// Powerbox button picks a person through Sandstorm - and, unlike the control
|
||||
// #2405 removed, adds them to the board that is open, through the same
|
||||
// inviteUserToBoard checks. Redeeming the request itself goes through
|
||||
// imports/sandstorm/bridgeClient.mjs (tests/sandstormCapnpRpc.test.cjs).
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
@@ -13,7 +14,9 @@ const path = require('path');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = relative => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
const sidebar = read('client/components/sidebar/sidebar.jade');
|
||||
const sandstorm = read('sandstorm.js');
|
||||
const sidebarJs = read('client/components/sidebar/sidebar.js');
|
||||
const shared = read('sandstorm.js');
|
||||
const server = read('server/sandstorm.js');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
@@ -22,13 +25,35 @@ function test(name, fn) {
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
test('Sandstorm uses the normal permission-checked board member picker', () => {
|
||||
const members = sidebar.slice(
|
||||
sidebar.indexOf('template(name="membersWidget")'),
|
||||
sidebar.indexOf('template(name="boardOrgGeneral")'),
|
||||
);
|
||||
const members = sidebar.slice(
|
||||
sidebar.indexOf('template(name="membersWidget")'),
|
||||
sidebar.indexOf('template(name="boardOrgGeneral")'),
|
||||
);
|
||||
|
||||
test('Sandstorm keeps the normal permission-checked board member picker', () => {
|
||||
assert.match(members, /if currentUser\.canInviteToBoard\s+a\.member\.add-member\.js-manage-board-members/);
|
||||
assert.doesNotMatch(members, /sandstorm-powerbox-request-identity/);
|
||||
});
|
||||
|
||||
test('the Powerbox button is back, for Sandstorm and for people who may invite', () => {
|
||||
assert.match(members,
|
||||
/if currentUser\.canInviteToBoard\s+a\.member\.add-member\.js-manage-board-members[^\n]*\n\s+i\.fa\.fa-plus\n\s+if isSandstorm\n\s+a\.member\.add-member\.sandstorm-powerbox-request-identity/,
|
||||
'inside canInviteToBoard, and only when isSandstorm');
|
||||
});
|
||||
|
||||
test('the button names the open board, and the claim invites the person to it', () => {
|
||||
assert.match(sidebarJs, /'click \.sandstorm-powerbox-request-identity'\(\) \{\s*window\.sandstormRequestIdentity\(Session\.get\('currentBoard'\)\);/);
|
||||
assert.match(shared, /window\.sandstormRequestIdentity = function\(boardId\)/);
|
||||
assert.match(shared, /'sandstormClaimIdentityRequest',\s*response\.token,\s*response\.descriptor,\s*boardId,/);
|
||||
assert.match(server, /async sandstormClaimIdentityRequest\(token, descriptor, boardId\)/);
|
||||
assert.match(server, /check\(boardId, Match\.Maybe\(String\)\)/);
|
||||
assert.match(server, /if \(!this\.userId\) throw new Meteor\.Error\('error-notAuthorized'\)/,
|
||||
'nobody but a logged-in grain user may redeem a request (negative)');
|
||||
assert.match(server, /await Meteor\.callAsync\('inviteUserToBoard', login\.userId, boardId\)/,
|
||||
'the same checks as the member picker, not a direct board update');
|
||||
const method = server.slice(server.indexOf('async sandstormClaimIdentityRequest('),
|
||||
server.indexOf('return { userId: login.userId };'));
|
||||
assert.doesNotMatch(method, /Boards\.|members/,
|
||||
'the claim must not add members around inviteUserToBoard (negative)');
|
||||
});
|
||||
|
||||
test('Sandstorm cannot create an unusable local email account from the picker', () => {
|
||||
@@ -40,9 +65,9 @@ test('Sandstorm cannot create an unusable local email account from the picker',
|
||||
});
|
||||
|
||||
test('grain configure permission remains the source of global admin rights', () => {
|
||||
assert.match(sandstorm, /const isAdmin = permissions\.indexOf\('configure'\) > -1/);
|
||||
assert.match(sandstorm, /Users\.updateAsync\(userId, \{ \$set: \{ isAdmin \} \}\)/);
|
||||
assert.match(sandstorm, /'services\.sandstorm\.permissions': 'configure'/);
|
||||
assert.match(server, /const isAdmin = permissions\.indexOf\('configure'\) > -1/);
|
||||
assert.match(server, /Users\.updateAsync\(userId, \{ \$set: \{ isAdmin \} \}\)/);
|
||||
assert.match(server, /'services\.sandstorm\.permissions': 'configure'/);
|
||||
});
|
||||
|
||||
console.log(`\nsandstormMultiBoardMembers: ${passed} tests passed`);
|
||||
@@ -85,7 +85,7 @@ test('no workflow hard-codes an npm version (negative)', () => {
|
||||
|
||||
test('the Sandstorm sources describe the Node they ship (negative)', () => {
|
||||
for (const rel of ['sandstorm-src/start.js', 'sandstorm-src/build-deps.sh', 'sandstorm-pkgdef.capnp',
|
||||
'sandstorm.js', 'docs/Platforms/FOSS/Container/Sandstorm/Meteor3/Migration.md']) {
|
||||
'sandstorm.js', 'server/sandstorm.js', 'docs/Platforms/FOSS/Container/Sandstorm/Meteor3/Migration.md']) {
|
||||
assert.ok(!/\bNode ?24\b|node24/i.test(read(rel)), `${rel} still says Node 24`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,11 +146,13 @@ test('a non-duplicate error from the update propagates unchanged', async () => {
|
||||
|
||||
// --- wiring: sandstorm.js actually uses the helpers ------------------------------
|
||||
|
||||
test('sandstorm.js probes case-insensitively and claims via the racy-safe helper', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'sandstorm.js'), 'utf8');
|
||||
test('server/sandstorm.js probes case-insensitively and claims via the racy-safe helper', () => {
|
||||
// The Users.after.insert hook is in the server half since sandstorm.js was
|
||||
// split so the client could load the shared half.
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'server/sandstorm.js'), 'utf8');
|
||||
assert.ok(
|
||||
/from ['"]\/models\/lib\/sandstormUsername['"]/.test(src),
|
||||
'sandstorm.js must import from /models/lib/sandstormUsername',
|
||||
'server/sandstorm.js must import from /models/lib/sandstormUsername',
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('usernameCaseInsensitiveRegex(candidate)'),
|
||||
|
||||
Reference in new issue
Block a user