Split shared models from Meteor 3 server runtime

This commit is contained in:
Harry Adel committed 2026-03-24 01:50:39 +02:00
1 parent 8ffc0cb5ff
commit 63ce45c538
171 files changed
+10539 -11006

No files matched your search

+113 -43
View File
@@ -1,53 +1,123 @@
/**
* Collection extensions shim (Meteor 3.4 migration).
*
* CRITICAL: This file must use ONLY require() — NO import statements.
* import statements are hoisted and can trigger evaluation chains that
* reach model files (via reactiveCache → i18n → translation.js) BEFORE
* collection2 has patched Mongo.Collection.prototype.attachSchema.
*
* With require(), each call completes synchronously before the next,
* guaranteeing this execution order:
* 1. Mongo loaded
* 2. SimpleSchema loaded + set as global
* 3. collection2 loaded (patches attachSchema onto Mongo.Collection)
* 4. Collection.helpers() shim installed
*
* Only THEN can model files safely call attachSchema/helpers.
* This module only patches collection prototype helpers that older app code
* still relies on. SimpleSchema now lives in `/imports/simpleSchema`.
*/
const MeteorPackage = typeof Package !== 'undefined' ? Package.meteor : undefined;
const MongoPackage = typeof Package !== 'undefined' ? Package.mongo : undefined;
const CollectionHooksPackage =
typeof Package !== 'undefined' ? Package['matb33:collection-hooks'] : undefined;
const Meteor = MeteorPackage && MeteorPackage.Meteor;
const Mongo = MongoPackage && MongoPackage.Mongo;
const CollectionHooks = CollectionHooksPackage && CollectionHooksPackage.CollectionHooks;
const { Mongo } = require('meteor/mongo');
// Load SimpleSchema and set as global (used by 30+ model files without importing)
const SimpleSchema = require('meteor/aldeed:simple-schema').default;
if (typeof window !== 'undefined') window.SimpleSchema = SimpleSchema;
else if (typeof global !== 'undefined') global.SimpleSchema = SimpleSchema;
// Register collection2 schema extensions removed from simple-schema v2
SimpleSchema.extendOptions(['denyUpdate', 'denyInsert']);
// Ensure collection2 augments Mongo.Collection.prototype with attachSchema
require('meteor/aldeed:collection2');
// Shim for dburles:collection-helpers (removed — absorbed into Meteor 3.4 core)
if (!Mongo.Collection.prototype.helpers) {
Mongo.Collection.prototype.helpers = function (helpers) {
if (this._transform && !this._helpers)
throw new Meteor.Error(
"Can't apply helpers to '" +
this._name +
"' a transform function already exists!",
if (Mongo && Mongo.Collection && Mongo.Collection.prototype && !Mongo.Collection.prototype.helpers) {
Mongo.Collection.prototype.helpers = function helpers(helpersMap) {
if (this._transform && !this._helpersConstructor) {
throw new Error(
`Can't apply helpers to '${this._name}': a transform function already exists.`,
);
if (!this._helpers) {
this._helpers = function Document(doc) {
return Object.assign(this, doc);
};
this._transform = (doc) => new this._helpers(doc);
}
Object.keys(helpers).forEach(
(key) => (this._helpers.prototype[key] = helpers[key]),
);
if (!this._helpersConstructor) {
this._helpersConstructor = function CollectionDocument(doc) {
Object.assign(this, doc);
};
this._transform = doc => new this._helpersConstructor(doc);
}
Object.keys(helpersMap).forEach(key => {
this._helpersConstructor.prototype[key] = helpersMap[key];
});
};
}
if (Mongo && Mongo.Collection && Mongo.Collection.prototype && !Mongo.Collection.prototype.attachSchema) {
Mongo.Collection.prototype.attachSchema = function attachSchema(schema) {
if (schema && schema._schemaDefinition) {
schema._schema = schema._schemaDefinition;
}
this._simpleSchema = schema;
return this;
};
}
if (Mongo && Mongo.Collection && Mongo.Collection.prototype && !Mongo.Collection.prototype.simpleSchema) {
Mongo.Collection.prototype.simpleSchema = function simpleSchema() {
return this._simpleSchema;
};
}
if (Mongo && Mongo.Collection && CollectionHooks && !Mongo.Collection._wekanHookBootstrapPatched) {
const OriginalCollection = Mongo.Collection;
const originalExtendCollectionInstance = CollectionHooks.extendCollectionInstance;
function ensureHookSurface(collection, constructor) {
if (!collection || collection._wekanHookSurfaceReady) {
return collection;
}
const safeConstructor =
constructor && constructor.prototype ? constructor : OriginalCollection;
if (
Meteor &&
Meteor.isServer &&
(!collection._collection ||
typeof collection._collection.insertAsync !== 'function' ||
typeof collection._collection.updateAsync !== 'function' ||
typeof collection._collection.removeAsync !== 'function')
) {
Object.defineProperty(collection, '_collection', {
value: collection,
configurable: true,
enumerable: false,
writable: true,
});
}
originalExtendCollectionInstance.call(CollectionHooks, collection, safeConstructor);
Object.defineProperty(collection, '_wekanHookSurfaceReady', {
value: true,
configurable: true,
enumerable: false,
writable: true,
});
return collection;
}
CollectionHooks.extendCollectionInstance = function extendCollectionInstance(collection, constructor) {
return ensureHookSurface(collection, constructor);
};
function PatchedCollection(...args) {
const ret = OriginalCollection.apply(this, args);
const collection =
ret && typeof ret === 'object' ? ret : this;
ensureHookSurface(collection, OriginalCollection);
return ret;
}
PatchedCollection.prototype = OriginalCollection.prototype;
PatchedCollection.prototype.constructor = PatchedCollection;
Object.keys(OriginalCollection).forEach(key => {
PatchedCollection[key] = OriginalCollection[key];
});
Object.defineProperty(PatchedCollection, '_wekanHookBootstrapPatched', {
value: true,
configurable: true,
enumerable: false,
writable: true,
});
Mongo.Collection = PatchedCollection;
if (Meteor) {
Meteor.Collection = PatchedCollection;
}
}
module.exports = {};
+129
View File
@@ -0,0 +1,129 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
class ReactiveValueCache {
constructor(compare, shouldStop) {
this.shouldStop = shouldStop || (() => true);
this.compare = compare || ((a, b) => a === b);
this.values = {};
this.deps = {};
}
ensureDependency(key) {
if (!this.deps[key]) {
this.deps[key] = new Tracker.Dependency();
}
return this.deps[key];
}
checkDeletion(key) {
const dep = this.ensureDependency(key);
if (dep.hasDependents()) {
return false;
}
delete this.values[key];
delete this.deps[key];
return true;
}
del(key) {
const dep = this.ensureDependency(key);
delete this.values[key];
if (this.checkDeletion(key)) {
return;
}
dep.changed();
}
set(key, data, bypassCompare) {
const dep = this.ensureDependency(key);
const current = this.values[key];
this.values[key] = data;
if (!this.compare(current, data) || bypassCompare) {
dep.changed();
}
}
get(key) {
const data = this.values[key];
if (Tracker.currentComputation) {
const dep = this.ensureDependency(key);
dep.depend();
Tracker.currentComputation.onStop(() => {
if (!this.shouldStop(key)) {
return;
}
this.checkDeletion(key);
});
}
return data;
}
}
class DataCache {
constructor(getData, options) {
this.options = {
timeout: 60 * 1000,
...(typeof options === 'function' ? { compare: options } : options),
};
this.getData = getData;
this.cache = new ReactiveValueCache(this.options.compare, () => false);
this.timeouts = {};
this.computations = {};
}
ensureComputation(key) {
if (this.timeouts[key]) {
clearTimeout(this.timeouts[key]);
delete this.timeouts[key];
}
if (this.computations[key] && !this.computations[key].stopped) {
return;
}
this.computations[key] = Tracker.nonreactive(() =>
Tracker.autorun(() => {
this.cache.set(key, this.getData(key));
}),
);
this.computations[key].onInvalidate(() => this.checkStop(key));
}
checkStop(key) {
if (this.cache.ensureDependency(key).hasDependents()) {
return;
}
if (this.timeouts[key]) {
clearTimeout(this.timeouts[key]);
delete this.timeouts[key];
}
this.timeouts[key] = setTimeout(() => {
if (!this.computations[key]) {
return;
}
this.computations[key].stop();
delete this.computations[key];
this.cache.del(key);
}, this.options.timeout);
}
get(key) {
if (!Tracker.currentComputation) {
let data = this.cache.get(key);
if (data == null) {
data = this.getData(key);
this.cache.set(key, data);
this.checkStop(key);
}
return data;
}
this.ensureComputation(key);
const data = this.cache.get(key);
Tracker.currentComputation.onStop(() => this.checkStop(key));
return data;
}
}
export { DataCache };
export default DataCache;
+78 -9
View File
@@ -1,10 +1,79 @@
import { DataCache } from '@wekanteam/meteor-reactive-cache';
import { Meteor } from 'meteor/meteor';
import { EJSON } from 'meteor/ejson';
import { DataCache } from '/imports/lib/dataCache';
import { groupBy, indexBy } from '/imports/lib/collectionHelpers';
import Settings from '../models/settings';
function lazyCollectionProxy(loadCollection) {
return new Proxy(
{},
{
get(_target, prop) {
const collection = loadCollection();
const value = collection[prop];
return typeof value === 'function' ? value.bind(collection) : value;
},
set(_target, prop, value) {
const collection = loadCollection();
collection[prop] = value;
return true;
},
has(_target, prop) {
return prop in loadCollection();
},
ownKeys() {
return Reflect.ownKeys(loadCollection());
},
getOwnPropertyDescriptor(_target, prop) {
const descriptor = Object.getOwnPropertyDescriptor(
loadCollection(),
prop,
);
if (descriptor) {
return descriptor;
}
return {
configurable: true,
enumerable: true,
writable: true,
value: loadCollection()[prop],
};
},
},
);
}
const Actions = lazyCollectionProxy(() => require('/models/actions').default);
const Activities = lazyCollectionProxy(() => require('/models/activities').default);
const Attachments = lazyCollectionProxy(() => require('/models/attachments').default);
const Avatars = lazyCollectionProxy(() => require('/models/avatars').default);
const Boards = lazyCollectionProxy(() => require('/models/boards').default);
const CardCommentReactions = lazyCollectionProxy(
() => require('/models/cardCommentReactions').default,
);
const CardComments = lazyCollectionProxy(() => require('/models/cardComments').default);
const Cards = lazyCollectionProxy(() => require('/models/cards').default);
const ChecklistItems = lazyCollectionProxy(() => require('/models/checklistItems').default);
const Checklists = lazyCollectionProxy(() => require('/models/checklists').default);
const CustomFields = lazyCollectionProxy(() => require('/models/customFields').default);
const ImpersonatedUsers = lazyCollectionProxy(
() => require('/models/impersonatedUsers').default,
);
const Integrations = lazyCollectionProxy(() => require('/models/integrations').default);
const InvitationCodes = lazyCollectionProxy(() => require('/models/invitationCodes').default);
const Lists = lazyCollectionProxy(() => require('/models/lists').default);
const Org = lazyCollectionProxy(() => require('/models/org').default);
const Rules = lazyCollectionProxy(() => require('/models/rules').default);
const SessionData = lazyCollectionProxy(() => require('/models/usersessiondata').default);
const Settings = lazyCollectionProxy(() => require('/models/settings').default);
const Swimlanes = lazyCollectionProxy(() => require('/models/swimlanes').default);
const Team = lazyCollectionProxy(() => require('/models/team').default);
const Translation = lazyCollectionProxy(() => require('/models/translation').default);
const Triggers = lazyCollectionProxy(() => require('/models/triggers').default);
const Users = lazyCollectionProxy(() => require('/models/users').default);
// Server isn't reactive, so search for the data always.
// All methods are async for Meteor 3.0 compatibility.
ReactiveCacheServer = {
const ReactiveCacheServer = {
async getBoard(idOrFirstObjectSelector = {}, options = {}) {
const ret = await Boards.findOneAsync(idOrFirstObjectSelector, options);
return ret;
@@ -136,7 +205,7 @@ ReactiveCacheServer = {
return ret;
},
async getAvatar(idOrFirstObjectSelector = {}, options = {}) {
const ret = Avatars.findOne(idOrFirstObjectSelector, options);
const ret = await Avatars.findOneAsync(idOrFirstObjectSelector, options);
return ret;
},
async getAvatars(selector = {}, options = {}, getQuery = false) {
@@ -291,7 +360,7 @@ ReactiveCacheServer = {
// only the Client is reactive
// saving the result has a big advantage if the query is big and often searched for the same data again and again
// if the data is changed in the client, the data is saved to the server and depending code is reactive called again
ReactiveCacheClient = {
const ReactiveCacheClient = {
getBoard(idOrFirstObjectSelector = {}, options = {}) {
const idOrFirstObjectSelect = { idOrFirstObjectSelector, options };
if (!this.__board) {
@@ -1016,7 +1085,7 @@ ReactiveCacheClient = {
// Methods are NOT async - they return a Promise on server (from async ReactiveCacheServer)
// and synchronous data on client (from ReactiveCacheClient).
// Server callers must await; client code uses the return value directly.
ReactiveCache = {
const ReactiveCache = {
getBoard(idOrFirstObjectSelector = {}, options = {}) {
if (Meteor.isServer) {
return ReactiveCacheServer.getBoard(idOrFirstObjectSelector, options);
@@ -1417,7 +1486,7 @@ ReactiveCache = {
};
// Server isn't reactive, so search for the data always.
ReactiveMiniMongoIndexServer = {
const ReactiveMiniMongoIndexServer = {
async getSubTasksWithParentId(parentId, addSelect = {}, options = {}) {
let ret = [];
if (parentId) {
@@ -1462,7 +1531,7 @@ ReactiveMiniMongoIndexServer = {
};
// Client side little MiniMongo DB "Index"
ReactiveMiniMongoIndexClient = {
const ReactiveMiniMongoIndexClient = {
getSubTasksWithParentId(parentId, addSelect = {}, options = {}) {
let ret = [];
if (parentId) {
@@ -1581,7 +1650,7 @@ ReactiveMiniMongoIndexClient = {
// having this class here has several advantages:
// - The Programmer hasn't to care about in which context he call's this class
// - having all queries together in 1 class to make it possible to see which queries in Wekan happens, e.g. with console.log
ReactiveMiniMongoIndex = {
const ReactiveMiniMongoIndex = {
getSubTasksWithParentId(parentId, addSelect = {}, options = {}) {
let ret;
if (Meteor.isServer) {
+11
View File
@@ -0,0 +1,11 @@
const schemaPackage = require('meteor/aldeed:simple-schema');
const SimpleSchema =
schemaPackage.default || schemaPackage.SimpleSchema || schemaPackage;
if (!SimpleSchema._wekanExtendedOptions) {
SimpleSchema.extendOptions(['index', 'unique']);
SimpleSchema._wekanExtendedOptions = true;
}
module.exports = { SimpleSchema };
+3 -19
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
AccessibilitySettings = new Mongo.Collection('accessibilitySettings');
const AccessibilitySettings = new Mongo.Collection('accessibilitySettings');
AccessibilitySettings.attachSchema(
new SimpleSchema({
@@ -32,7 +34,6 @@ AccessibilitySettings.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -45,21 +46,4 @@ AccessibilitySettings.attachSchema(
}),
);
AccessibilitySettings.allow({
async update(userId) {
const user = await Meteor.users.findOneAsync(userId);
return user && user.isAdmin;
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await AccessibilitySettings._collection.createIndexAsync({ modifiedAt: -1 });
const accessibilitySetting = AccessibilitySettings.findOne({});
if (!accessibilitySetting) {
AccessibilitySettings.insert({ enabled: false, sort: 0 });
}
});
}
export default AccessibilitySettings;
+3 -43
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
AccountSettings = new Mongo.Collection('accountSettings');
const AccountSettings = new Mongo.Collection('accountSettings');
AccountSettings.attachSchema(
new SimpleSchema({
@@ -13,7 +15,6 @@ AccountSettings.attachSchema(
},
sort: {
type: Number,
decimal: true,
},
createdAt: {
type: Date,
@@ -31,7 +32,6 @@ AccountSettings.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -44,46 +44,6 @@ AccountSettings.attachSchema(
}),
);
AccountSettings.allow({
async update(userId) {
const user = await Meteor.users.findOneAsync(userId);
return user && user.isAdmin;
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await AccountSettings._collection.createIndexAsync({ modifiedAt: -1 });
AccountSettings.upsert(
{ _id: 'accounts-allowEmailChange' },
{
$setOnInsert: {
booleanValue: false,
sort: 0,
},
},
);
AccountSettings.upsert(
{ _id: 'accounts-allowUserNameChange' },
{
$setOnInsert: {
booleanValue: false,
sort: 1,
},
},
);
AccountSettings.upsert(
{ _id: 'accounts-allowUserDelete' },
{
$setOnInsert: {
booleanValue: false,
sort: 0,
},
},
);
});
}
AccountSettings.helpers({
allowEmailChange() {
return AccountSettings.findOne('accounts-allowEmailChange').booleanValue;
+2 -20
View File
@@ -1,19 +1,7 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
Actions = new Mongo.Collection('actions');
Actions.allow({
async insert(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
async update(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
async remove(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
});
const Actions = new Mongo.Collection('actions');
Actions.before.insert((userId, doc) => {
doc.createdAt = new Date();
@@ -31,10 +19,4 @@ Actions.helpers({
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await Actions._collection.createIndexAsync({ modifiedAt: -1 });
});
}
export default Actions;
+3 -356
View File
@@ -1,3 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
import { findWhere, where } from '/imports/lib/collectionHelpers';
@@ -11,7 +13,7 @@ import { findWhere, where } from '/imports/lib/collectionHelpers';
// instance if a user archive a card, and un-archive it a few seconds later we
// should remove both activities assuming it was an error the user decided to
// revert.
Activities = new Mongo.Collection('activities');
const Activities = new Mongo.Collection('activities');
Activities.helpers({
board() {
@@ -75,359 +77,4 @@ Activities.before.insert((userId, doc) => {
doc.modifiedAt = doc.createdAt;
});
if (Meteor.isServer) {
Activities.after.insert(async (userId, doc) => {
const activity = Activities._transform(doc);
try {
await RulesHelper.executeRules(activity);
} catch (e) {
console.error('RulesHelper.executeRules error for activity', doc._id, e);
}
});
// For efficiency create indexes on the date of creation, and on the date of
// creation in conjunction with the card or board id, as corresponding views
// are largely used in the App. See #524.
Meteor.startup(async () => {
await Activities._collection.createIndexAsync({ createdAt: -1 });
await Activities._collection.createIndexAsync({ modifiedAt: -1 });
await Activities._collection.createIndexAsync({ cardId: 1, createdAt: -1 });
await Activities._collection.createIndexAsync({ boardId: 1, createdAt: -1 });
await Activities._collection.createIndexAsync(
{ commentId: 1 },
{ partialFilterExpression: { commentId: { $exists: true } } },
);
await Activities._collection.createIndexAsync(
{ attachmentId: 1 },
{ partialFilterExpression: { attachmentId: { $exists: true } } },
);
await Activities._collection.createIndexAsync(
{ customFieldId: 1 },
{ partialFilterExpression: { customFieldId: { $exists: true } } },
);
// Label activity did not work yet, unable to edit labels when tried this.
//Activities._collection.dropIndex({ labelId: 1 }, { "indexKey": -1 });
//Activities._collection.dropIndex({ labelId: 1 }, { partialFilterExpression: { labelId: { $exists: true } } });
});
Activities.after.insert(async (userId, doc) => {
const activity = Activities._transform(doc);
let participants = [];
let watchers = [];
let title = 'act-activity-notify';
const board = await ReactiveCache.getBoard(activity.boardId);
const description = `act-${activity.activityType}`;
const params = {
activityId: activity._id,
};
if (activity.userId) {
// No need send notification to user of activity
// participants = [...new Set([...participants, activity.userId])];
const user = await activity.user();
if (user) {
if (user.getName()) {
params.user = user.getName();
}
if (user.emails) {
params.userEmails = user.emails;
}
if (activity.userId) {
params.userId = activity.userId;
}
}
}
if (activity.boardId) {
if (board.title) {
if (board.title.length > 0) {
params.board = board.title;
} else {
params.board = '';
}
} else {
params.board = '';
}
title = 'act-withBoardTitle';
params.url = board.absoluteUrl();
params.boardId = activity.boardId;
}
if (activity.oldBoardId) {
const oldBoard = await activity.oldBoard();
if (oldBoard) {
watchers = [...new Set([...watchers, ...(oldBoard.watchers || [])])];
params.oldBoard = oldBoard.title;
params.oldBoardId = activity.oldBoardId;
}
}
if (activity.memberId) {
participants = [...new Set([...participants, activity.memberId])];
params.member = (await activity.member()).getName();
}
if (activity.listId) {
const list = await activity.list();
if (list) {
if (list.watchers !== undefined) {
watchers = [...new Set([...watchers, ...(list.watchers || [])])];
}
params.list = list.title;
params.listId = activity.listId;
}
}
if (activity.oldListId) {
const oldList = await activity.oldList();
if (oldList) {
watchers = [...new Set([...watchers, ...(oldList.watchers || [])])];
params.oldList = oldList.title;
params.oldListId = activity.oldListId;
}
}
if (activity.oldSwimlaneId) {
const oldSwimlane = await activity.oldSwimlane();
if (oldSwimlane) {
watchers = [...new Set([...watchers, ...(oldSwimlane.watchers || [])])];
params.oldSwimlane = oldSwimlane.title;
params.oldSwimlaneId = activity.oldSwimlaneId;
}
}
if (activity.cardId) {
const card = await activity.card();
participants = [...new Set([...participants, card.userId, ...(card.members || [])])];
watchers = [...new Set([...watchers, ...(card.watchers || [])])];
params.card = card.title;
title = 'act-withCardTitle';
params.url = card.absoluteUrl();
params.cardId = activity.cardId;
}
if (activity.swimlaneId) {
const swimlane = await activity.swimlane();
params.swimlane = swimlane.title;
params.swimlaneId = activity.swimlaneId;
}
if (activity.commentId) {
const comment = await activity.comment();
params.comment = comment.text;
let hasMentions = false; // Track if comment has @mentions
if (board) {
const comment = params.comment;
// Build knownUsers with async user lookups
const knownUsers = [];
for (const member of board.members) {
const u = await ReactiveCache.getUser(member.userId);
if (u) {
member.username = u.username;
member.emails = u.emails;
}
knownUsers.push(member);
}
// Match @mentions including usernames with @ symbols (like email addresses)
// Pattern matches: @username, @user@example.com, @"quoted username"
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.@-]+))/gi;
let currentMention;
while ((currentMention = mentionRegex.exec(comment)) !== null) {
/*eslint no-unused-vars: ["error", { "varsIgnorePattern": "[iI]gnored" }]*/
const [ignored, quoteduser, simple] = currentMention;
const username = quoteduser || simple;
// Removed the check that prevented self-mentions from creating notifications
// Users can now mention themselves in comments to create notifications
if (activity.boardId && username === 'board_members') {
// mentions all board members
const validUserIds = [];
for (const u of knownUsers) {
const user = await ReactiveCache.getUser(u.userId);
if (user && user._id) {
validUserIds.push(u.userId);
}
}
watchers = [...new Set([...watchers, ...validUserIds])];
title = 'act-atUserComment';
hasMentions = true;
} else if (activity.boardId && username === 'board_assignees') {
// mentions all assignees of all cards on the board
const allCards = await ReactiveCache.getCards({ boardId: activity.boardId });
const assigneeIds = [];
for (const card of allCards) {
if (card.assignees && card.assignees.length > 0) {
for (const assigneeId of card.assignees) {
// Only add if the user exists and is a board member
const user = await ReactiveCache.getUser(assigneeId);
if (user && findWhere(knownUsers, { userId: assigneeId })) {
assigneeIds.push(assigneeId);
}
}
}
}
watchers = [...new Set([...watchers, ...assigneeIds])];
title = 'act-atUserComment';
hasMentions = true;
} else if (activity.cardId && username === 'card_members') {
// mentions all card members if assigned
const card = await activity.card();
if (card && card.members && card.members.length > 0) {
// Filter to only valid users who are board members
const validMembers = [];
for (const memberId of card.members) {
const user = await ReactiveCache.getUser(memberId);
if (user && user._id && findWhere(knownUsers, { userId: memberId })) {
validMembers.push(memberId);
}
}
watchers = [...new Set([...watchers, ...validMembers])];
}
title = 'act-atUserComment';
hasMentions = true;
} else if (activity.cardId && username === 'card_assignees') {
// mentions all assignees of the current card
const card = await activity.card();
if (card && card.assignees && card.assignees.length > 0) {
// Filter to only valid users who are board members
const validAssignees = [];
for (const assigneeId of card.assignees) {
const user = await ReactiveCache.getUser(assigneeId);
if (user && user._id && findWhere(knownUsers, { userId: assigneeId })) {
validAssignees.push(assigneeId);
}
}
watchers = [...new Set([...watchers, ...validAssignees])];
}
title = 'act-atUserComment';
hasMentions = true;
} else {
const atUser = findWhere(knownUsers, { username });
if (!atUser) {
continue;
}
const uid = atUser.userId;
params.atUsername = username;
params.atEmails = atUser.emails;
title = 'act-atUserComment';
watchers = [...new Set([...watchers, uid])];
hasMentions = true;
}
}
}
params.commentId = comment._id;
params.hasMentions = hasMentions; // Store for later use
}
if (activity.attachmentId) {
params.attachment = activity.attachmentName;
params.attachmentId = activity.attachmentId;
}
if (activity.checklistId) {
const checklist = await activity.checklist();
if (checklist) {
if (checklist.title) {
params.checklist = checklist.title;
}
}
}
if (activity.checklistItemId) {
const checklistItem = await activity.checklistItem();
if (checklistItem) {
if (checklistItem.title) {
params.checklistItem = checklistItem.title;
}
}
}
if (activity.customFieldId) {
const customField = await activity.customField();
if (customField) {
if (customField.name) {
params.customField = customField.name;
}
if (activity.value) {
params.customFieldValue = activity.value;
}
}
}
// Label activity did not work yet, unable to edit labels when tried this.
if (activity.labelId) {
const label = await activity.label();
if (label) {
if (label.name) {
params.label = label.name;
} else if (label.color) {
params.label = label.color;
}
if (label._id) {
params.labelId = label._id;
}
}
}
if (
(!activity.timeKey || activity.timeKey === 'dueAt') &&
activity.timeValue
) {
// due time reminder, if it doesn't have old value, it's a brand new set, need some differentiation
title = activity.timeOldValue ? 'act-withDue' : 'act-newDue';
}
['timeValue', 'timeOldValue'].forEach((key) => {
// copy time related keys & values to params
const value = activity[key];
if (value) params[key] = value;
});
if (board) {
const activeMemberIds = (board.members || []).filter(m => m.isActive === true).map(m => m.userId);
const BIGEVENTS = process.env.BIGEVENTS_PATTERN; // if environment BIGEVENTS_PATTERN is set, any activityType matching it is important
if (BIGEVENTS) {
try {
const atype = activity.activityType;
if (new RegExp(BIGEVENTS).exec(atype)) {
watchers = [...new Set([...watchers, ...activeMemberIds])]; // notify all active members for important events
}
} catch (e) {
// passed env var BIGEVENTS_PATTERN is not a valid regex
}
}
const watchingUsers = where(board.watchers, { level: 'watching' }).map(x => x.userId);
const trackingUsers = where(board.watchers, { level: 'tracking' }).map(x => x.userId);
// Only add board watchers if there were no @mentions in the comment
// When users are explicitly @mentioned, only notify those users
if (!params.hasMentions) {
watchers = [...new Set([
...watchers,
...watchingUsers,
...participants.filter(x => trackingUsers.includes(x)),
])];
}
// Ensure notifications only go to active members of the current board.
watchers = watchers.filter(x => activeMemberIds.includes(x));
}
(await Notifications.getUsers(watchers)).forEach((user) => {
// Skip if user is undefined or doesn't have an _id (e.g., deleted user or invalid ID)
if (!user || !user._id) return;
// Don't notify a user of their own behavior, EXCEPT for self-mentions
const isSelfMention = (user._id === userId && title === 'act-atUserComment');
if (user._id !== userId || isSelfMention) {
Notifications.notify(user, title, description, params);
}
});
const integrations = await ReactiveCache.getIntegrations({
boardId: { $in: [board._id, Integrations.Const.GLOBAL_WEBHOOK_ID] },
// type: 'outgoing-webhooks', // all types
enabled: true,
activities: { $in: [description, 'all'] },
});
if (integrations.length > 0) {
params.watchers = watchers;
integrations.forEach((integration) => {
Meteor.call(
'outgoingWebhooks',
integration,
description,
params,
() => {
return;
},
);
});
}
});
}
export default Activities;
+3 -20
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
Announcements = new Mongo.Collection('announcements');
const Announcements = new Mongo.Collection('announcements');
Announcements.attachSchema(
new SimpleSchema({
@@ -18,7 +20,6 @@ Announcements.attachSchema(
},
sort: {
type: Number,
decimal: true,
},
createdAt: {
type: Date,
@@ -36,7 +37,6 @@ Announcements.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -49,21 +49,4 @@ Announcements.attachSchema(
}),
);
Announcements.allow({
async update(userId) {
const user = await Meteor.users.findOneAsync(userId);
return user && user.isAdmin;
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await Announcements._collection.createIndexAsync({ modifiedAt: -1 });
const announcements = Announcements.findOne({});
if (!announcements) {
Announcements.insert({ enabled: false, sort: 0 });
}
});
}
export default Announcements;
+5
View File
@@ -0,0 +1,5 @@
import { Mongo } from 'meteor/mongo';
const AttachmentMigrationStatus = new Mongo.Collection('attachmentMigrationStatus');
export default AttachmentMigrationStatus;
+4 -143
View File
@@ -1,12 +1,9 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import SimpleSchema from 'meteor/aldeed:simple-schema';
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS } from '/models/lib/fileStoreStrategy';
// DISABLED: S3 storage removed due to Node.js compatibility
// import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
import { Mongo } from 'meteor/mongo';
const { SimpleSchema } = require('/imports/simpleSchema');
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS } from '/models/lib/fileStoreConstants';
// Attachment Storage Settings Collection
AttachmentStorageSettings = new Mongo.Collection('attachmentStorageSettings');
const AttachmentStorageSettings = new Mongo.Collection('attachmentStorageSettings');
// Schema for attachment storage settings
AttachmentStorageSettings.attachSchema(
@@ -253,140 +250,4 @@ AttachmentStorageSettings.helpers({
}
});
// Server-side methods
if (Meteor.isServer) {
// Get or create default settings
Meteor.methods({
async 'getAttachmentStorageSettings'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
let settings = await AttachmentStorageSettings.findOneAsync({});
if (!settings) {
// Create default settings
settings = {
defaultStorage: STORAGE_NAME_FILESYSTEM,
storageConfig: {
filesystem: {
enabled: true,
path: process.env.WRITABLE_PATH ? `${process.env.WRITABLE_PATH}/attachments` : '/data/attachments'
},
gridfs: {
enabled: true
},
// DISABLED: S3 storage removed due to Node.js compatibility
// s3: {
// enabled: false
// }
},
uploadSettings: {
maxFileSize: process.env.ATTACHMENTS_UPLOAD_MAX_SIZE ? parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) : 0,
allowedMimeTypes: process.env.ATTACHMENTS_UPLOAD_MIME_TYPES ? process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',').map(t => t.trim()) : []
},
migrationSettings: {
autoMigrate: false,
batchSize: 10,
delayMs: 1000,
cpuThreshold: 70
},
createdBy: this.userId,
updatedBy: this.userId
};
await AttachmentStorageSettings.insertAsync(settings);
settings = await AttachmentStorageSettings.findOneAsync({});
}
return settings;
},
async 'updateAttachmentStorageSettings'(settings) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
// Validate settings
const schema = AttachmentStorageSettings.simpleSchema();
schema.validate(settings);
// Update settings
const result = await AttachmentStorageSettings.upsertAsync(
{},
{
$set: {
...settings,
updatedBy: this.userId,
updatedAt: new Date()
}
}
);
return result;
},
async 'getDefaultAttachmentStorage'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const settings = await AttachmentStorageSettings.findOneAsync({});
return settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
},
async 'setDefaultAttachmentStorage'(storageName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS].includes(storageName)) {
throw new Meteor.Error('invalid-storage', 'Invalid storage backend');
}
const result = await AttachmentStorageSettings.upsertAsync(
{},
{
$set: {
defaultStorage: storageName,
updatedBy: this.userId,
updatedAt: new Date()
}
}
);
return result;
}
});
// Publication for settings
Meteor.publish('attachmentStorageSettings', async function() {
if (!this.userId) {
return this.ready();
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
return this.ready();
}
return AttachmentStorageSettings.find({});
});
}
export default AttachmentStorageSettings;
+9 -347
View File
@@ -1,59 +1,11 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { FilesCollection } from 'meteor/ostrio:files';
import { isFileValid } from './fileValidation';
import { createBucket } from './lib/grid/createBucket';
import fs from 'fs';
import path from 'path';
import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs } from '/models/lib/attachmentStoreStrategy';
// DISABLED: S3 storage strategy removed due to Node.js compatibility
// import { AttachmentStoreStrategyS3 } from '/models/lib/attachmentStoreStrategy';
import FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS} from '/models/lib/fileStoreStrategy';
// DISABLED: S3 storage removed due to Node.js compatibility
// import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
import AttachmentStorageSettings from './attachmentStorageSettings';
import { generateUniversalAttachmentUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
let attachmentUploadExternalProgram;
let attachmentUploadMimeTypes = [];
let attachmentUploadSize = 0;
let attachmentBucket;
let storagePath;
if (Meteor.isServer) {
attachmentBucket = createBucket('attachments');
if (process.env.ATTACHMENTS_UPLOAD_MIME_TYPES) {
attachmentUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',');
attachmentUploadMimeTypes = attachmentUploadMimeTypes.map(value => value.trim());
}
if (process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) {
attachmentUploadSize = parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE);
if (isNaN(attachmentUploadSize)) {
attachmentUploadSize = 0
}
}
if (process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM) {
attachmentUploadExternalProgram = process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM;
if (!attachmentUploadExternalProgram.includes("{file}")) {
attachmentUploadExternalProgram = undefined;
}
}
storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'attachments');
}
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(AttachmentStoreStrategyFilesystem, storagePath, AttachmentStoreStrategyGridFs, attachmentBucket);
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
// XXX Enforce a schema for the Attachments FilesCollection
// see: https://github.com/VeliovGroup/Meteor-Files/wiki/Schema
Attachments = new FilesCollection({
const Attachments = new FilesCollection({
debug: false, // Change to `true` for debugging
collectionName: 'attachments',
allowClientCode: true,
@@ -93,17 +45,11 @@ Attachments = new FilesCollection({
// keep the original filename
return str;
},
storagePath() {
const ret = fileStoreStrategyFactory.storagePath;
return ret;
},
onBeforeUpload(file) {
// SECURITY: Sanitize filename to prevent path traversal attacks
// Import sanitizeFilename from fileStoreStrategy - but since we can't import here,
// we'll implement a minimal inline version to be safe
if (file.name && typeof file.name === 'string') {
// Use path.basename to strip directory components and prevent path traversal
let safeName = path.basename(file.name);
// Strip directory components (client-safe alternative to path.basename)
let safeName = file.name.split(/[\\/]/).pop();
// Remove null bytes
safeName = safeName.replace(/\0/g, '');
// Remove path traversal sequences
@@ -136,72 +82,6 @@ Attachments = new FilesCollection({
return true;
},
async onAfterUpload(fileObj) {
// Get default storage backend from settings
let defaultStorage = STORAGE_NAME_FILESYSTEM;
try {
const settings = await AttachmentStorageSettings.findOneAsync({});
if (settings) {
defaultStorage = settings.getDefaultStorage();
}
} catch (error) {
console.warn('Could not get attachment storage settings, using default:', error);
}
// Set initial storage to filesystem (temporary)
Object.keys(fileObj.versions).forEach(versionName => {
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
});
this._now = new Date();
await Attachments.updateAsync({ _id: fileObj._id }, { $set: { "versions" : fileObj.versions } });
await Attachments.updateAsync({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio" : this._now } });
// Use selected storage backend or copy storage if specified
let storageDestination = fileObj.meta.copyStorage || defaultStorage;
// Only migrate if the destination is different from filesystem
if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
}
},
interceptDownload(http, fileObj, versionName) {
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
return ret;
},
onAfterRemove(filesInput) {
const files = normalizeRemovedFiles(filesInput);
files.forEach(fileObj => {
if (!fileObj || !fileObj.versions) {
return;
}
Object.keys(fileObj.versions).forEach(versionName => {
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
});
});
},
// We authorize the attachment download either:
// - if the board is public, everyone (even unconnected) can download it
// - if the board is private, only board members can download it
protected(fileObj) {
// file may have been deleted already again after upload validation failed
if (!fileObj) {
return false;
}
// ostrio:files calls this synchronously inside _checkAccess (no await support),
// so use synchronous Boards.findOne which works within the Fiber context
// that ostrio:files sets up via Meteor.bindEnvironment.
const board = Boards.findOne(fileObj.meta.boardId);
if (!board) {
return false;
}
if (board.isPublic()) {
return true;
}
return board.hasMember(this.userId);
},
});
function normalizeRemovedFiles(filesInput) {
@@ -236,230 +116,12 @@ function normalizeRemovedFiles(filesInput) {
return [];
}
if (Meteor.isServer) {
Attachments.allow({
async insert(userId, fileObj) {
// ReadOnly users cannot upload attachments
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(fileObj.boardId));
},
async update(userId, fileObj, fields) {
// SECURITY: The 'name' field is sanitized in onBeforeUpload and server-side methods,
// but we block direct client-side $set operations on 'versions.*.path' to prevent
// path traversal attacks via storage migration exploits.
// Export normalizeRemovedFiles for use in server file
export { normalizeRemovedFiles };
// Block direct updates to version paths (the attack vector)
const hasPathUpdate = fields.some(field => field.includes('versions') && field.includes('path'));
if (hasPathUpdate) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update attachment version paths:', fields);
}
return false;
}
// Allow normal updates for file upload/management
const allowedFields = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta', 'versions'];
const isAllowedField = fields.every(field => {
// Allow field itself or nested properties like 'versions.original'
const baseField = field.split('.')[0];
return allowedFields.includes(baseField);
});
if (!isAllowedField) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update restricted attachment fields:', fields);
}
return false;
}
// ReadOnly users cannot update attachments
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(fileObj.boardId));
},
async remove(userId, fileObj) {
// Additional security check: ensure the file belongs to the board the user has access to
if (!fileObj || !fileObj.boardId) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attachment removal: file has no boardId');
}
return false;
}
const board = await Boards.findOneAsync(fileObj.boardId);
if (!board) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attachment removal: board not found');
}
return false;
}
// ReadOnly users cannot delete attachments
return allowIsBoardMemberWithWriteAccess(userId, board);
},
fetch: ['meta', 'boardId'],
});
Meteor.methods({
// Validate image URL to prevent SVG-based DoS attacks
validateImageUrl(imageUrl) {
check(imageUrl, String);
if (!imageUrl) {
return { valid: false, reason: 'Empty URL' };
}
// Block SVG files and data URIs
if (imageUrl.endsWith('.svg') || imageUrl.startsWith('data:image/svg')) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked potentially malicious SVG image URL:', imageUrl);
}
return { valid: false, reason: 'SVG images are blocked for security reasons' };
}
// Block data URIs that could contain malicious content
if (imageUrl.startsWith('data:')) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked data URI image URL:', imageUrl);
}
return { valid: false, reason: 'Data URIs are blocked for security reasons' };
}
// Validate URL format
try {
const url = new URL(imageUrl);
// Only allow http and https protocols
if (!['http:', 'https:'].includes(url.protocol)) {
return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' };
}
} catch (e) {
return { valid: false, reason: 'Invalid URL format' };
}
return { valid: true };
},
async moveAttachmentToStorage(fileObjId, storageDestination) {
check(fileObjId, String);
check(storageDestination, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
}
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
// Allowlist storage destinations
const allowedDestinations = ['fs', 'gridfs', 's3'];
if (!allowedDestinations.includes(storageDestination)) {
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
}
moveToStorage(fileObj, storageDestination, fileStoreStrategyFactory);
},
async renameAttachment(fileObjId, newName) {
check(fileObjId, String);
check(newName, String);
const currentUserId = Meteor.userId();
if (!currentUserId) {
throw new Meteor.Error('not-authorized', 'User must be logged in');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('file-not-found', 'Attachment not found');
}
// Verify the user has permission to modify this attachment
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
if (!allowIsBoardMember(currentUserId, board)) {
if (process.env.DEBUG === 'true') {
console.warn(`Blocked unauthorized attachment rename attempt: user ${currentUserId} tried to rename attachment ${fileObjId} in board ${fileObj.boardId}`);
}
throw new Meteor.Error('not-authorized', 'You do not have permission to modify this attachment');
}
rename(fileObj, newName, fileStoreStrategyFactory);
},
async validateAttachment(fileObjId) {
check(fileObjId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
}
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
const isValid = await isFileValid(fileObj, attachmentUploadMimeTypes, attachmentUploadSize, attachmentUploadExternalProgram);
if (!isValid) {
Attachments.remove(fileObjId);
}
},
async validateAttachmentAndMoveToStorage(fileObjId, storageDestination) {
check(fileObjId, String);
check(storageDestination, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
}
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
// Allowlist storage destinations
const allowedDestinations = ['fs', 'gridfs', 's3'];
if (!allowedDestinations.includes(storageDestination)) {
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
}
await Meteor.callAsync('validateAttachment', fileObjId);
const fileObjAfter = await ReactiveCache.getAttachment(fileObjId);
if (fileObjAfter) {
Meteor.defer(() => Meteor.call('moveAttachmentToStorage', fileObjId, storageDestination));
}
},
});
Meteor.startup(async () => {
await Attachments.collection.createIndexAsync({ 'meta.cardId': 1 });
const storagePath = fileStoreStrategyFactory.storagePath;
if (!fs.existsSync(storagePath)) {
console.log("create storagePath because it doesn't exist: " + storagePath);
fs.mkdirSync(storagePath, { recursive: true });
}
});
}
// Add backward compatibility methods - available on both client and server
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
// Backward compatibility stubs (overridden in attachments.server.js with real implementations)
Attachments.getAttachmentWithBackwardCompatibility = async () => null;
Attachments.getAttachmentsWithBackwardCompatibility = async () => [];
// Override Attachments.link to use universal short URLs and to bypass
// the `check(fileRef, Object)` inside the original which fails when fileRef is
+306
View File
@@ -0,0 +1,306 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { isFileValid } from './fileValidation';
import { createBucket } from './lib/grid/createBucket';
import fs from 'fs';
import path from 'path';
import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs } from '/models/lib/attachmentStoreStrategy';
import FileStoreStrategyFactory, { moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS } from '/models/lib/fileStoreStrategy';
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
import AttachmentStorageSettings from './attachmentStorageSettings';
import Attachments, { normalizeRemovedFiles } from './attachments';
import Boards from '/models/boards';
import { allowIsBoardMember } from '/server/lib/utils';
// ---------------------------------------------------------------------------
// Server-only configuration
// ---------------------------------------------------------------------------
let attachmentUploadExternalProgram;
let attachmentUploadMimeTypes = [];
let attachmentUploadSize = 0;
const attachmentBucket = createBucket('attachments');
const storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'attachments');
if (process.env.ATTACHMENTS_UPLOAD_MIME_TYPES) {
attachmentUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',');
attachmentUploadMimeTypes = attachmentUploadMimeTypes.map(value => value.trim());
}
if (process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) {
attachmentUploadSize = parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE);
if (isNaN(attachmentUploadSize)) {
attachmentUploadSize = 0;
}
}
if (process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM) {
attachmentUploadExternalProgram = process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM;
if (!attachmentUploadExternalProgram.includes("{file}")) {
attachmentUploadExternalProgram = undefined;
}
}
// ---------------------------------------------------------------------------
// File store strategy factory
// ---------------------------------------------------------------------------
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(
AttachmentStoreStrategyFilesystem, storagePath,
AttachmentStoreStrategyGridFs, attachmentBucket,
);
// ---------------------------------------------------------------------------
// Assign server-only FilesCollection callbacks
// ---------------------------------------------------------------------------
Attachments.storagePath = function () {
return fileStoreStrategyFactory.storagePath;
};
Attachments.onAfterUpload = async function (fileObj) {
// Get default storage backend from settings
let defaultStorage = STORAGE_NAME_FILESYSTEM;
try {
const settings = await AttachmentStorageSettings.findOneAsync({});
if (settings) {
defaultStorage = settings.getDefaultStorage();
}
} catch (error) {
console.warn('Could not get attachment storage settings, using default:', error);
}
// Set initial storage to filesystem (temporary)
Object.keys(fileObj.versions).forEach(versionName => {
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
});
this._now = new Date();
await Attachments.updateAsync({ _id: fileObj._id }, { $set: { "versions": fileObj.versions } });
await Attachments.updateAsync({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio": this._now } });
// Use selected storage backend or copy storage if specified
let storageDestination = fileObj.meta.copyStorage || defaultStorage;
// Only migrate if the destination is different from filesystem
if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
}
};
Attachments.interceptDownload = function (http, fileObj, versionName) {
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
return ret;
};
Attachments.onAfterRemove = function (filesInput) {
const files = normalizeRemovedFiles(filesInput);
files.forEach(fileObj => {
if (!fileObj || !fileObj.versions) {
return;
}
Object.keys(fileObj.versions).forEach(versionName => {
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
});
});
};
// We authorize the attachment download either:
// - if the board is public, everyone (even unconnected) can download it
// - if the board is private, only board members can download it
Attachments.protected = function (fileObj) {
// file may have been deleted already again after upload validation failed
if (!fileObj) {
return false;
}
// ostrio:files calls this synchronously inside _checkAccess (no await support),
// so use synchronous Boards.findOne.
const board = Boards.findOne(fileObj.meta.boardId);
if (!board) {
return false;
}
if (board.isPublic()) {
return true;
}
return board.hasMember(this.userId);
};
// ---------------------------------------------------------------------------
// Backward compatibility methods (override client stubs)
// ---------------------------------------------------------------------------
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
// ---------------------------------------------------------------------------
// Meteor methods
// ---------------------------------------------------------------------------
Meteor.methods({
// Validate image URL to prevent SVG-based DoS attacks
validateImageUrl(imageUrl) {
check(imageUrl, String);
if (!imageUrl) {
return { valid: false, reason: 'Empty URL' };
}
// Block SVG files and data URIs
if (imageUrl.endsWith('.svg') || imageUrl.startsWith('data:image/svg')) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked potentially malicious SVG image URL:', imageUrl);
}
return { valid: false, reason: 'SVG images are blocked for security reasons' };
}
// Block data URIs that could contain malicious content
if (imageUrl.startsWith('data:')) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked data URI image URL:', imageUrl);
}
return { valid: false, reason: 'Data URIs are blocked for security reasons' };
}
// Validate URL format
try {
const url = new URL(imageUrl);
// Only allow http and https protocols
if (!['http:', 'https:'].includes(url.protocol)) {
return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' };
}
} catch (e) {
return { valid: false, reason: 'Invalid URL format' };
}
return { valid: true };
},
async moveAttachmentToStorage(fileObjId, storageDestination) {
check(fileObjId, String);
check(storageDestination, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
}
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
// Allowlist storage destinations
const allowedDestinations = ['fs', 'gridfs', 's3'];
if (!allowedDestinations.includes(storageDestination)) {
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
}
moveToStorage(fileObj, storageDestination, fileStoreStrategyFactory);
},
async renameAttachment(fileObjId, newName) {
check(fileObjId, String);
check(newName, String);
const currentUserId = this.userId;
if (!currentUserId) {
throw new Meteor.Error('not-authorized', 'User must be logged in');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('file-not-found', 'Attachment not found');
}
// Verify the user has permission to modify this attachment
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
if (!allowIsBoardMember(currentUserId, board)) {
if (process.env.DEBUG === 'true') {
console.warn(`Blocked unauthorized attachment rename attempt: user ${currentUserId} tried to rename attachment ${fileObjId} in board ${fileObj.boardId}`);
}
throw new Meteor.Error('not-authorized', 'You do not have permission to modify this attachment');
}
rename(fileObj, newName, fileStoreStrategyFactory);
},
async validateAttachment(fileObjId) {
check(fileObjId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
}
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
const isValid = await isFileValid(fileObj, attachmentUploadMimeTypes, attachmentUploadSize, attachmentUploadExternalProgram);
if (!isValid) {
await Attachments.removeAsync(fileObjId);
}
},
async validateAttachmentAndMoveToStorage(fileObjId, storageDestination) {
check(fileObjId, String);
check(storageDestination, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const fileObj = await ReactiveCache.getAttachment(fileObjId);
if (!fileObj) {
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
}
const board = await ReactiveCache.getBoard(fileObj.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
// Allowlist storage destinations
const allowedDestinations = ['fs', 'gridfs', 's3'];
if (!allowedDestinations.includes(storageDestination)) {
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
}
await Meteor.callAsync('validateAttachment', fileObjId);
const fileObjAfter = await ReactiveCache.getAttachment(fileObjId);
if (fileObjAfter) {
Meteor.defer(() => Meteor.call('moveAttachmentToStorage', fileObjId, storageDestination));
}
},
});
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
Meteor.startup(async () => {
await Attachments.collection.createIndexAsync({ 'meta.cardId': 1 });
const sp = fileStoreStrategyFactory.storagePath;
if (!fs.existsSync(sp)) {
console.log("create storagePath because it doesn't exist: " + sp);
fs.mkdirSync(sp, { recursive: true });
}
});
+9 -114
View File
@@ -1,52 +1,13 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { FilesCollection } from 'meteor/ostrio:files';
import { formatFleURL } from 'meteor/ostrio:files/lib';
import { isFileValid } from './fileValidation';
import { createBucket } from './lib/grid/createBucket';
import { TAPi18n } from '/imports/i18n';
import fs from 'fs';
import path from 'path';
import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
import { generateUniversalAvatarUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
import { generateUniversalAvatarUrl } from '/models/lib/universalUrlGenerator';
const filesize = require('filesize');
const { filesize } = require('filesize');
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
let avatarsUploadExternalProgram;
let avatarsUploadMimeTypes = [];
let avatarsUploadSize = 72000;
let avatarsBucket;
let storagePath;
if (Meteor.isServer) {
if (process.env.AVATARS_UPLOAD_MIME_TYPES) {
avatarsUploadMimeTypes = process.env.AVATARS_UPLOAD_MIME_TYPES.split(',');
avatarsUploadMimeTypes = avatarsUploadMimeTypes.map(value => value.trim());
}
if (process.env.AVATARS_UPLOAD_MAX_SIZE) {
avatarsUploadSize_ = parseInt(process.env.AVATARS_UPLOAD_MAX_SIZE);
if (typeof avatarsUploadSize_ === 'number' && avatarsUploadSize_ > 0) {
avatarsUploadSize = avatarsUploadSize_;
}
}
if (process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM) {
avatarsUploadExternalProgram = process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM;
if (!avatarsUploadExternalProgram.includes("{file}")) {
avatarsUploadExternalProgram = undefined;
}
}
avatarsBucket = createBucket('avatars');
storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars');
}
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket);
Avatars = new FilesCollection({
const Avatars = new FilesCollection({
debug: false, // Change to `true` for debugging
collectionName: 'avatars',
allowClientCode: true,
@@ -81,10 +42,6 @@ Avatars = new FilesCollection({
// keep the original filename
return str;
},
storagePath() {
const ret = fileStoreStrategyFactory.storagePath;
return ret;
},
onBeforeUpload(file) {
// Block SVG files for avatars to prevent XSS attacks
if (file.name && file.name.toLowerCase().endsWith('.svg')) {
@@ -104,55 +61,7 @@ Avatars = new FilesCollection({
if (file.size <= avatarsUploadSize && file.type.startsWith('image/')) {
return true;
}
return TAPi18n.__('avatar-too-big', {size: filesize(avatarsUploadSize)});
},
async onAfterUpload(fileObj) {
// current storage is the filesystem, update object and database
Object.keys(fileObj.versions).forEach(versionName => {
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
});
Avatars.update({ _id: fileObj._id }, { $set: { "versions": fileObj.versions } });
const isValid = await isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram);
if (isValid) {
// Set avatar URL using universal URL generator (URL-agnostic)
const universalUrl = generateUniversalAvatarUrl(fileObj._id);
const user = await ReactiveCache.getUser(fileObj.userId);
user.setAvatarUrl(universalUrl);
} else {
Avatars.remove(fileObj._id);
}
},
interceptDownload(http, fileObj, versionName) {
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
return ret;
},
async onBeforeRemove(filesInput) {
const files = normalizeRemovedFiles(filesInput);
for (const fileObj of files) {
if (fileObj && fileObj.userId) {
const user = await ReactiveCache.getUser(fileObj.userId);
user.setAvatarUrl('');
}
}
return true;
},
onAfterRemove(filesInput) {
const files = normalizeRemovedFiles(filesInput);
files.forEach(fileObj => {
if (!fileObj || !fileObj.versions) {
return;
}
Object.keys(fileObj.versions).forEach(versionName => {
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
});
});
return getTAPi18n().__('avatar-too-big', {size: filesize(avatarsUploadSize)});
},
});
@@ -188,25 +97,11 @@ function normalizeRemovedFiles(filesInput) {
return [];
}
function isOwner(userId, doc) {
return userId && userId === doc.userId;
}
// Export normalizeRemovedFiles and avatarsUploadSize setter for use in server file
export { normalizeRemovedFiles };
if (Meteor.isServer) {
Avatars.allow({
insert: isOwner,
update: isOwner,
remove: isOwner,
fetch: ['userId'],
});
Meteor.startup(() => {
const storagePath = fileStoreStrategyFactory.storagePath;
if (!fs.existsSync(storagePath)) {
console.log("create storagePath because it doesn't exist: " + storagePath);
fs.mkdirSync(storagePath, { recursive: true });
}
});
export function setAvatarsUploadSize(size) {
avatarsUploadSize = size;
}
// Override the link method to use universal URLs
+124
View File
@@ -0,0 +1,124 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { isFileValid } from './fileValidation';
import { createBucket } from './lib/grid/createBucket';
import fs from 'fs';
import path from 'path';
import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
import { generateUniversalAvatarUrl } from '/models/lib/universalUrlGenerator';
import Avatars, { normalizeRemovedFiles, setAvatarsUploadSize } from './avatars';
// ---------------------------------------------------------------------------
// Server-only configuration
// ---------------------------------------------------------------------------
let avatarsUploadExternalProgram;
let avatarsUploadMimeTypes = [];
let avatarsUploadSize = 72000;
if (process.env.AVATARS_UPLOAD_MIME_TYPES) {
avatarsUploadMimeTypes = process.env.AVATARS_UPLOAD_MIME_TYPES.split(',');
avatarsUploadMimeTypes = avatarsUploadMimeTypes.map(value => value.trim());
}
if (process.env.AVATARS_UPLOAD_MAX_SIZE) {
const avatarsUploadSize_ = parseInt(process.env.AVATARS_UPLOAD_MAX_SIZE);
if (typeof avatarsUploadSize_ === 'number' && avatarsUploadSize_ > 0) {
avatarsUploadSize = avatarsUploadSize_;
// Sync the upload size to the shared module for onBeforeUpload validation
setAvatarsUploadSize(avatarsUploadSize);
}
}
if (process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM) {
avatarsUploadExternalProgram = process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM;
if (!avatarsUploadExternalProgram.includes("{file}")) {
avatarsUploadExternalProgram = undefined;
}
}
const avatarsBucket = createBucket('avatars');
const storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars');
// ---------------------------------------------------------------------------
// File store strategy factory
// ---------------------------------------------------------------------------
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(
FileStoreStrategyFilesystem, storagePath,
FileStoreStrategyGridFs, avatarsBucket,
);
// ---------------------------------------------------------------------------
// Assign server-only FilesCollection callbacks
// ---------------------------------------------------------------------------
Avatars.storagePath = function () {
return fileStoreStrategyFactory.storagePath;
};
Avatars.onAfterUpload = async function (fileObj) {
// current storage is the filesystem, update object and database
Object.keys(fileObj.versions).forEach(versionName => {
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
});
await Avatars.updateAsync({ _id: fileObj._id }, { $set: { "versions": fileObj.versions } });
const isValid = await isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram);
if (isValid) {
// Set avatar URL using universal URL generator (URL-agnostic)
const universalUrl = generateUniversalAvatarUrl(fileObj._id);
const user = await ReactiveCache.getUser(fileObj.userId);
user.setAvatarUrl(universalUrl);
} else {
await Avatars.removeAsync(fileObj._id);
}
};
Avatars.interceptDownload = function (http, fileObj, versionName) {
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
return ret;
};
Avatars.onBeforeRemove = async function (filesInput) {
const files = normalizeRemovedFiles(filesInput);
for (const fileObj of files) {
if (fileObj && fileObj.userId) {
const user = await ReactiveCache.getUser(fileObj.userId);
user.setAvatarUrl('');
}
}
return true;
};
Avatars.onAfterRemove = function (filesInput) {
const files = normalizeRemovedFiles(filesInput);
files.forEach(fileObj => {
if (!fileObj || !fileObj.versions) {
return;
}
Object.keys(fileObj.versions).forEach(versionName => {
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
});
});
};
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
Meteor.startup(() => {
const sp = fileStoreStrategyFactory.storagePath;
if (!fs.existsSync(sp)) {
console.log("create storagePath because it doesn't exist: " + sp);
fs.mkdirSync(sp, { recursive: true });
}
});
+133 -903
View File
File diff suppressed because it is too large. Load diff
+10 -23
View File
@@ -1,4 +1,6 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
const commentReactionSchema = new SimpleSchema({
reactionCodepoint: {
@@ -11,10 +13,12 @@ const commentReactionSchema = new SimpleSchema({
}
},
},
userIds: { type: [String], defaultValue: [] }
userIds: { type: Array, defaultValue: [] },
'userIds.$': { type: String }
});
CardCommentReactions = new Mongo.Collection('card_comment_reactions');
const CardCommentReactions = new Mongo.Collection('card_comment_reactions');
export default CardCommentReactions;
/**
* All reactions of a card comment
@@ -43,28 +47,11 @@ CardCommentReactions.attachSchema(
optional: false
},
reactions: {
type: [commentReactionSchema],
type: Array,
defaultValue: []
},
'reactions.$': {
type: commentReactionSchema,
}
}),
);
CardCommentReactions.allow({
async insert(userId, doc) {
return allowIsBoardMember(userId, await Boards.findOneAsync(doc.boardId));
},
async update(userId, doc) {
return allowIsBoardMember(userId, await Boards.findOneAsync(doc.boardId));
},
async remove(userId, doc) {
return allowIsBoardMember(userId, await Boards.findOneAsync(doc.boardId));
},
fetch: ['boardId'],
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await CardCommentReactions._collection.createIndexAsync({ cardCommentId: 1 }, { unique: true });
});
}
+10 -226
View File
@@ -1,6 +1,10 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
import escapeForRegex from 'escape-string-regexp';
import DOMPurify from 'dompurify';
import Boards from '/models/boards';
import CardCommentReactions from '/models/cardCommentReactions';
const { SimpleSchema } = require('/imports/simpleSchema');
// Server-side text sanitization function
function sanitizeText(text) {
@@ -9,7 +13,7 @@ function sanitizeText(text) {
return text.replace(/<[^>]*>/g, '');
}
CardComments = new Mongo.Collection('card_comments');
const CardComments = new Mongo.Collection('card_comments');
/**
* A comment on a card
@@ -40,7 +44,6 @@ CardComments.attachSchema(
* when was the comment created
*/
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert) {
@@ -54,7 +57,6 @@ CardComments.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -80,25 +82,11 @@ CardComments.attachSchema(
}),
);
CardComments.allow({
async insert(userId, doc) {
// ReadOnly users cannot add comments. Only members who can comment are allowed.
return allowIsBoardMemberCommentOnly(userId, await Boards.findOneAsync(doc.boardId));
},
async update(userId, doc) {
return userId === doc.userId || allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
async remove(userId, doc) {
return userId === doc.userId || allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
fetch: ['userId', 'boardId'],
});
CardComments.helpers({
copy(newCardId) {
this.cardId = newCardId;
delete this._id;
CardComments.insert(this);
return CardComments.insertAsync(this);
},
user() {
@@ -139,9 +127,9 @@ CardComments.helpers({
// If no reaction doc exists yet create otherwise update reaction set
if (!!cardCommentReactions) {
return CardCommentReactions.update({ _id: cardCommentReactions._id }, { $set: { reactions } });
return CardCommentReactions.updateAsync({ _id: cardCommentReactions._id }, { $set: { reactions } });
} else {
return CardCommentReactions.insert({
return CardCommentReactions.insertAsync({
boardId: this.boardId,
cardCommentId: this._id,
cardId: this.cardId,
@@ -156,7 +144,7 @@ CardComments.hookOptions.after.update = { fetchPrevious: false };
async function commentCreation(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
Activities.insert({
await Activities.insertAsync({
userId,
activityType: 'addComment',
boardId: doc.boardId,
@@ -189,208 +177,4 @@ CardComments.textSearch = async (userId, textArray) => {
return comments;
};
if (Meteor.isServer) {
// Comments are often fetched within a card, so we create an index to make these
// queries more efficient.
Meteor.startup(async () => {
await CardComments._collection.createIndexAsync({ modifiedAt: -1 });
await CardComments._collection.createIndexAsync({ cardId: 1, createdAt: -1 });
});
CardComments.after.insert(async (userId, doc) => {
await commentCreation(userId, doc);
});
CardComments.after.update(async (userId, doc) => {
const card = await ReactiveCache.getCard(doc.cardId);
Activities.insert({
userId,
activityType: 'editComment',
boardId: doc.boardId,
cardId: doc.cardId,
commentId: doc._id,
listId: card.listId,
swimlaneId: card.swimlaneId,
});
});
CardComments.before.remove(async (userId, doc) => {
const card = await ReactiveCache.getCard(doc.cardId);
Activities.insert({
userId,
activityType: 'deleteComment',
boardId: doc.boardId,
cardId: doc.cardId,
commentId: doc._id,
listId: card.listId,
swimlaneId: card.swimlaneId,
});
const activity = await ReactiveCache.getActivity({ commentId: doc._id });
if (activity) {
Activities.remove(activity._id);
}
});
}
//CARD COMMENT REST API
if (Meteor.isServer) {
/**
* @operation get_all_comments
* @summary Get all comments attached to a card
*
* @param {string} boardId the board ID of the card
* @param {string} cardId the ID of the card
* @return_type [{_id: string,
* comment: string,
* authorId: string}]
*/
JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', async function (
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: (await ReactiveCache.getCardComments({
boardId: paramBoardId,
cardId: paramCardId,
})).map(function (doc) {
return {
_id: doc._id,
comment: doc.text,
authorId: doc.userId,
};
}),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation get_comment
* @summary Get a comment on a card
*
* @param {string} boardId the board ID of the card
* @param {string} cardId the ID of the card
* @param {string} commentId the ID of the comment to retrieve
* @return_type CardComments
*/
JsonRoutes.add(
'GET',
'/api/boards/:boardId/cards/:cardId/comments/:commentId',
async function (req, res) {
try {
const paramBoardId = req.params.boardId;
const paramCommentId = req.params.commentId;
const paramCardId = req.params.cardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: await ReactiveCache.getCardComment({
_id: paramCommentId,
cardId: paramCardId,
boardId: paramBoardId,
}),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
},
);
/**
* @operation new_comment
* @summary Add a comment on a card
*
* @param {string} boardId the board ID of the card
* @param {string} cardId the ID of the card
* @param {string} comment the content of the comment
* @return_type {_id: string}
*/
JsonRoutes.add(
'POST',
'/api/boards/:boardId/cards/:cardId/comments',
async function (req, res) {
try {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const id = CardComments.direct.insert({
userId: req.userId,
text: req.body.comment,
cardId: paramCardId,
boardId: paramBoardId,
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
const cardComment = await ReactiveCache.getCardComment({
_id: id,
cardId: paramCardId,
boardId: paramBoardId,
});
await commentCreation(req.userId, cardComment);
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
},
);
/**
* @operation delete_comment
* @summary Delete a comment on a card
*
* @param {string} boardId the board ID of the card
* @param {string} cardId the ID of the card
* @param {string} commentId the ID of the comment to delete
* @return_type {_id: string}
*/
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/cards/:cardId/comments/:commentId',
function (req, res) {
try {
const paramBoardId = req.params.boardId;
const paramCommentId = req.params.commentId;
const paramCardId = req.params.cardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
CardComments.remove({
_id: paramCommentId,
cardId: paramCardId,
boardId: paramBoardId,
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramCardId,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
},
);
}
export default CardComments;
+176 -1582
View File
File diff suppressed because it is too large. Load diff
+15 -279
View File
@@ -1,6 +1,10 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
import Activities from '/models/activities';
const { SimpleSchema } = require('/imports/simpleSchema');
ChecklistItems = new Mongo.Collection('checklistItems');
const ChecklistItems = new Mongo.Collection('checklistItems');
/**
* An item in a checklist
@@ -18,7 +22,6 @@ ChecklistItems.attachSchema(
* the sorting field of the item
*/
type: Number,
decimal: true,
},
isFinished: {
/**
@@ -55,7 +58,6 @@ ChecklistItems.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -68,22 +70,6 @@ ChecklistItems.attachSchema(
}),
);
ChecklistItems.allow({
async insert(userId, doc) {
// ReadOnly users cannot create checklist items
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
},
async update(userId, doc) {
// ReadOnly users cannot edit checklist items
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
},
async remove(userId, doc) {
// ReadOnly users cannot delete checklist items
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
},
fetch: ['userId', 'cardId'],
});
ChecklistItems.before.insert((userId, doc) => {
if (!doc.userId) {
doc.userId = userId;
@@ -113,10 +99,10 @@ ChecklistItems.helpers({
});
// Activities helper
async function itemCreation(userId, doc) {
export async function itemCreation(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
const boardId = card.boardId;
Activities.insert({
await Activities.insertAsync({
userId,
activityType: 'addChecklistItem',
cardId: doc.cardId,
@@ -129,13 +115,13 @@ async function itemCreation(userId, doc) {
});
}
function itemRemover(userId, doc) {
Activities.remove({
export async function itemRemover(userId, doc) {
await Activities.removeAsync({
checklistItemId: doc._id,
});
}
async function publishCheckActivity(userId, doc) {
export async function publishCheckActivity(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
const boardId = card.boardId;
let activityType;
@@ -155,10 +141,10 @@ async function publishCheckActivity(userId, doc) {
listId: card.listId,
swimlaneId: card.swimlaneId,
};
Activities.insert(act);
await Activities.insertAsync(act);
}
async function publishChekListCompleted(userId, doc) {
export async function publishChekListCompleted(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
const boardId = card.boardId;
const checklistId = doc.checklistId;
@@ -177,11 +163,11 @@ async function publishChekListCompleted(userId, doc) {
listId: card.listId,
swimlaneId: card.swimlaneId,
};
Activities.insert(act);
await Activities.insertAsync(act);
}
}
async function publishChekListUncompleted(userId, doc) {
export async function publishChekListUncompleted(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
const boardId = card.boardId;
const checklistId = doc.checklistId;
@@ -213,258 +199,8 @@ async function publishChekListUncompleted(userId, doc) {
listId: card.listId,
swimlaneId: card.swimlaneId,
};
Activities.insert(act);
await Activities.insertAsync(act);
}
}
// Activities
if (Meteor.isServer) {
Meteor.startup(async () => {
await ChecklistItems._collection.createIndexAsync({ modifiedAt: -1 });
await ChecklistItems._collection.createIndexAsync({ checklistId: 1 });
await ChecklistItems._collection.createIndexAsync({ cardId: 1 });
});
ChecklistItems.after.update(async (userId, doc, fieldNames) => {
await publishCheckActivity(userId, doc);
await publishChekListCompleted(userId, doc, fieldNames);
});
ChecklistItems.before.update(async (userId, doc, fieldNames) => {
await publishChekListUncompleted(userId, doc, fieldNames);
});
ChecklistItems.after.insert(async (userId, doc) => {
await itemCreation(userId, doc);
});
ChecklistItems.before.remove(async (userId, doc) => {
itemRemover(userId, doc);
const card = await ReactiveCache.getCard(doc.cardId);
const boardId = card.boardId;
Activities.insert({
userId,
activityType: 'removedChecklistItem',
cardId: doc.cardId,
boardId,
checklistId: doc.checklistId,
checklistItemId: doc._id,
checklistItemName: doc.title,
listId: card.listId,
swimlaneId: card.swimlaneId,
});
});
}
if (Meteor.isServer) {
/**
* @operation get_checklist_item
* @tag Checklists
* @summary Get a checklist item
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @param {string} checklistId the checklist ID
* @param {string} itemId the ID of the item
* @return_type ChecklistItems
*/
JsonRoutes.add(
'GET',
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
const paramChecklistId = req.params.checklistId;
const paramItemId = req.params.itemId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const checklistItem = await ReactiveCache.getChecklistItem(paramItemId);
if (checklistItem && checklistItem.cardId === paramCardId && checklistItem.checklistId === paramChecklistId) {
const card = await ReactiveCache.getCard(checklistItem.cardId);
if (card && card.boardId === paramBoardId) {
JsonRoutes.sendResult(res, {
code: 200,
data: checklistItem,
});
} else {
JsonRoutes.sendResult(res, {
code: 404,
});
}
} else {
JsonRoutes.sendResult(res, {
code: 404,
});
}
},
);
/**
* @operation new_checklist_item
* @summary add a new item to a checklist
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @param {string} checklistId the ID of the checklist
* @param {string} title the title of the new item
* @return_type {_id: string}
*/
JsonRoutes.add(
'POST',
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramChecklistId = req.params.checklistId;
const paramCardId = req.params.cardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const checklist = await ReactiveCache.getChecklist({
_id: paramChecklistId,
cardId: paramCardId,
});
if (checklist) {
const card = await ReactiveCache.getCard(paramCardId);
if (card && card.boardId === paramBoardId) {
const id = ChecklistItems.insert({
cardId: paramCardId,
checklistId: paramChecklistId,
title: req.body.title,
isFinished: false,
sort: 0,
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
} else {
JsonRoutes.sendResult(res, {
code: 404,
});
}
} else {
JsonRoutes.sendResult(res, {
code: 404,
});
}
},
);
/**
* @operation edit_checklist_item
* @tag Checklists
* @summary Edit a checklist item
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @param {string} checklistId the checklist ID
* @param {string} itemId the ID of the item
* @param {string} [isFinished] is the item checked?
* @param {string} [title] the new text of the item
* @return_type {_id: string}
*/
JsonRoutes.add(
'PUT',
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
const paramChecklistId = req.params.checklistId;
const paramItemId = req.params.itemId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const checklistItem = await ReactiveCache.getChecklistItem(paramItemId);
if (!checklistItem || checklistItem.cardId !== paramCardId || checklistItem.checklistId !== paramChecklistId) {
JsonRoutes.sendResult(res, {
code: 404,
});
return;
}
const card = await ReactiveCache.getCard(checklistItem.cardId);
if (!card || card.boardId !== paramBoardId) {
JsonRoutes.sendResult(res, {
code: 404,
});
return;
}
function isTrue(data) {
try {
return data.toLowerCase() === 'true';
} catch (error) {
return data;
}
}
if (req.body.hasOwnProperty('isFinished')) {
ChecklistItems.direct.update(
{ _id: paramItemId },
{ $set: { isFinished: isTrue(req.body.isFinished) } },
);
}
if (req.body.hasOwnProperty('title')) {
ChecklistItems.direct.update(
{ _id: paramItemId },
{ $set: { title: req.body.title } },
);
}
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramItemId,
},
});
},
);
/**
* @operation delete_checklist_item
* @tag Checklists
* @summary Delete a checklist item
*
* @description Note: this operation can't be reverted.
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @param {string} checklistId the checklist ID
* @param {string} itemId the ID of the item to be removed
* @return_type {_id: string}
*/
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
const paramChecklistId = req.params.checklistId;
const paramItemId = req.params.itemId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const checklistItem = await ReactiveCache.getChecklistItem(paramItemId);
if (!checklistItem || checklistItem.cardId !== paramCardId || checklistItem.checklistId !== paramChecklistId) {
JsonRoutes.sendResult(res, {
code: 404,
});
return;
}
const card = await ReactiveCache.getCard(checklistItem.cardId);
if (!card || card.boardId !== paramBoardId) {
JsonRoutes.sendResult(res, {
code: 404,
});
return;
}
ChecklistItems.direct.remove({ _id: paramItemId });
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramItemId,
},
});
},
);
}
export default ChecklistItems;
+8 -366
View File
@@ -1,6 +1,11 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ReactiveCache, ReactiveMiniMongoIndex } from '/imports/reactiveCache';
import Activities from '/models/activities';
import ChecklistItems from '/models/checklistItems';
const { SimpleSchema } = require('/imports/simpleSchema');
Checklists = new Mongo.Collection('checklists');
const Checklists = new Mongo.Collection('checklists');
/**
* A Checklist
@@ -32,7 +37,6 @@ Checklists.attachSchema(
* Creation date of the checklist
*/
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert) {
@@ -46,7 +50,6 @@ Checklists.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -61,7 +64,6 @@ Checklists.attachSchema(
* sorting value of the checklist
*/
type: Number,
decimal: true,
},
hideCheckedChecklistItems: {
/**
@@ -92,13 +94,13 @@ Checklists.helpers({
let copyObj = Object.assign({}, this);
delete copyObj._id;
copyObj.cardId = newCardId;
const newChecklistId = Checklists.insert(copyObj);
const newChecklistId = await Checklists.insertAsync(copyObj);
const items = await ReactiveCache.getChecklistItems({ checklistId: this._id });
for (const item of items) {
item._id = null;
item.checklistId = newChecklistId;
item.cardId = newCardId;
ChecklistItems.insert(item);
await ChecklistItems.insertAsync(item);
}
},
@@ -194,370 +196,10 @@ Checklists.helpers({
},
});
Checklists.allow({
async insert(userId, doc) {
// ReadOnly users cannot create checklists
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
},
async update(userId, doc) {
// ReadOnly users cannot edit checklists
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
},
async remove(userId, doc) {
// ReadOnly users cannot delete checklists
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
},
fetch: ['userId', 'cardId'],
});
Checklists.before.insert((userId, doc) => {
doc.createdAt = new Date();
if (!doc.userId) {
doc.userId = userId;
}
});
if (Meteor.isServer) {
Meteor.methods({
async moveChecklist(checklistId, newCardId) {
check(checklistId, String);
check(newCardId, String);
const checklist = await ReactiveCache.getChecklist(checklistId);
if (!checklist) {
throw new Meteor.Error('checklist-not-found', 'Checklist not found');
}
const newCard = await ReactiveCache.getCard(newCardId);
if (!newCard) {
throw new Meteor.Error('card-not-found', 'Target card not found');
}
// Check permissions on both source and target cards
const sourceCard = await ReactiveCache.getCard(checklist.cardId);
if (!allowIsBoardMemberByCard(this.userId, sourceCard)) {
throw new Meteor.Error('not-authorized', 'Not authorized to move checklist from source card');
}
if (!allowIsBoardMemberByCard(this.userId, newCard)) {
throw new Meteor.Error('not-authorized', 'Not authorized to move checklist to target card');
}
// Update activities
const activities = await ReactiveCache.getActivities({ checklistId });
for (const activity of activities) {
Activities.update(activity._id, {
$set: {
cardId: newCardId,
},
});
}
// Update checklist items
const checklistItems = await ReactiveCache.getChecklistItems({ checklistId });
for (const checklistItem of checklistItems) {
ChecklistItems.update(checklistItem._id, {
$set: {
cardId: newCardId,
},
});
}
// Update the checklist itself
Checklists.update(checklistId, {
$set: {
cardId: newCardId,
},
});
return checklistId;
},
});
Meteor.startup(async () => {
await Checklists._collection.createIndexAsync({ modifiedAt: -1 });
await Checklists._collection.createIndexAsync({ cardId: 1, createdAt: 1 });
});
Checklists.after.insert(async (userId, doc) => {
const card = await ReactiveCache.getCard(doc.cardId);
Activities.insert({
userId,
activityType: 'addChecklist',
cardId: doc.cardId,
boardId: card.boardId,
checklistId: doc._id,
checklistName: doc.title,
listId: card.listId,
swimlaneId: card.swimlaneId,
});
});
Checklists.before.remove(async (userId, doc) => {
const activities = await ReactiveCache.getActivities({ checklistId: doc._id });
const card = await ReactiveCache.getCard(doc.cardId);
if (activities) {
for (const activity of activities) {
Activities.remove(activity._id);
}
}
Activities.insert({
userId,
activityType: 'removeChecklist',
cardId: doc.cardId,
boardId: (await ReactiveCache.getCard(doc.cardId)).boardId,
checklistId: doc._id,
checklistName: doc.title,
listId: card.listId,
swimlaneId: card.swimlaneId,
});
});
}
if (Meteor.isServer) {
/**
* @operation get_all_checklists
* @summary Get the list of checklists attached to a card
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @return_type [{_id: string,
* title: string}]
*/
JsonRoutes.add(
'GET',
'/api/boards/:boardId/cards/:cardId/checklists',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
// Verify the card belongs to the board
const card = await ReactiveCache.getCard({
_id: paramCardId,
boardId: paramBoardId,
});
if (!card) {
JsonRoutes.sendResult(res, {
code: 404,
data: { error: 'Card not found or does not belong to the specified board' },
});
return;
}
const checklists = (await ReactiveCache.getChecklists({ cardId: paramCardId })).map(function(
doc,
) {
return {
_id: doc._id,
title: doc.title,
};
});
if (checklists) {
JsonRoutes.sendResult(res, {
code: 200,
data: checklists,
});
} else {
JsonRoutes.sendResult(res, {
code: 500,
});
}
},
);
/**
* @operation get_checklist
* @summary Get a checklist
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @param {string} checklistId the ID of the checklist
* @return_type {cardId: string,
* title: string,
* finishedAt: string,
* createdAt: string,
* sort: number,
* items: [{_id: string,
* title: string,
* isFinished: boolean}]}
*/
JsonRoutes.add(
'GET',
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramChecklistId = req.params.checklistId;
const paramCardId = req.params.cardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
// Verify the card belongs to the board
const card = await ReactiveCache.getCard({
_id: paramCardId,
boardId: paramBoardId,
});
if (!card) {
JsonRoutes.sendResult(res, {
code: 404,
data: { error: 'Card not found or does not belong to the specified board' },
});
return;
}
const checklist = await ReactiveCache.getChecklist({
_id: paramChecklistId,
cardId: paramCardId,
});
if (checklist) {
checklist.items = (await ReactiveCache.getChecklistItems({
checklistId: checklist._id,
})).map(function(doc) {
return {
_id: doc._id,
title: doc.title,
isFinished: doc.isFinished,
};
});
JsonRoutes.sendResult(res, {
code: 200,
data: checklist,
});
} else {
JsonRoutes.sendResult(res, {
code: 500,
});
}
},
);
/**
* @operation new_checklist
* @summary create a new checklist
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @param {string} title the title of the new checklist
* @param {string} [items] the list of items on the new checklist
* @return_type {_id: string}
*/
JsonRoutes.add(
'POST',
'/api/boards/:boardId/cards/:cardId/checklists',
async function(req, res) {
// Check user is logged in
//Authentication.checkLoggedIn(req.userId);
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
// Check user has permission to add checklist to the card
const board = await ReactiveCache.getBoard(paramBoardId);
const addPermission = allowIsBoardMemberCommentOnly(req.userId, board);
Authentication.checkAdminOrCondition(req.userId, addPermission);
const paramCardId = req.params.cardId;
// Verify the card belongs to the board
const card = await ReactiveCache.getCard({
_id: paramCardId,
boardId: paramBoardId,
});
if (!card) {
JsonRoutes.sendResult(res, {
code: 404,
data: { error: 'Card not found or does not belong to the specified board' },
});
return;
}
const id = Checklists.insert({
title: req.body.title,
cardId: paramCardId,
sort: 0,
});
if (id) {
let items = req.body.items || [];
if (typeof items === 'string') {
if (items === '') {
items = [];
} else {
items = [items];
}
}
items.forEach(function(item, idx) {
ChecklistItems.insert({
cardId: paramCardId,
checklistId: id,
title: item,
sort: idx,
});
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
} else {
JsonRoutes.sendResult(res, {
code: 400,
});
}
},
);
/**
* @operation delete_checklist
* @summary Delete a checklist
*
* @description The checklist will be removed, not put in the recycle bin.
*
* @param {string} boardId the board ID
* @param {string} cardId the card ID
* @param {string} checklistId the ID of the checklist to remove
* @return_type {_id: string}
*/
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
const paramChecklistId = req.params.checklistId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
// Verify the card belongs to the board
const card = await ReactiveCache.getCard({
_id: paramCardId,
boardId: paramBoardId,
});
if (!card) {
JsonRoutes.sendResult(res, {
code: 404,
data: { error: 'Card not found or does not belong to the specified board' },
});
return;
}
// Verify the checklist exists and belongs to the card
const checklist = await ReactiveCache.getChecklist({
_id: paramChecklistId,
cardId: paramCardId,
});
if (!checklist) {
JsonRoutes.sendResult(res, {
code: 404,
data: { error: 'Checklist not found or does not belong to the specified card' },
});
return;
}
Checklists.remove({ _id: paramChecklistId });
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramChecklistId,
},
});
},
);
}
export default Checklists;
+5 -2
View File
@@ -1,4 +1,6 @@
Counters = new Mongo.Collection('counters');
import { Mongo } from 'meteor/mongo';
const Counters = new Mongo.Collection('counters');
async function incrementCounterAsync(counterName, amount = 1) {
const result = await Counters.rawCollection().findOneAndUpdate(
@@ -12,4 +14,5 @@ async function incrementCounterAsync(counterName, amount = 1) {
// Alias for backward compatibility — all callers should use the async version
const incrementCounter = incrementCounterAsync;
export { incrementCounter, incrementCounterAsync };
export { Counters, incrementCounter, incrementCounterAsync };
export default Counters;
+5
View File
@@ -0,0 +1,5 @@
import { Mongo } from 'meteor/mongo';
const CronJobStatus = new Mongo.Collection('cronJobStatus');
export default CronJobStatus;
+23 -17
View File
@@ -1,6 +1,12 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { isEmptyObject } from 'jquery';
import Activities from '/models/activities';
import Boards from './boards';
import Cards from '/models/cards';
import CustomFields from '/models/customFields';
import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
export class CsvCreator {
constructor(data) {
@@ -127,8 +133,8 @@ export class CsvCreator {
}
this.fieldIndex = index;
}
createCustomFields(boardId) {
this.fieldIndex.customFields.forEach(customField => {
async createCustomFields(boardId) {
for (const customField of this.fieldIndex.customFields) {
let settings = {};
if (customField.type === 'dropdown') {
settings = {
@@ -143,7 +149,7 @@ export class CsvCreator {
} else {
settings = {};
}
const id = CustomFields.direct.insert({
const id = await CustomFields.direct.insertAsync({
name: customField.name,
type: customField.type,
settings,
@@ -155,10 +161,10 @@ export class CsvCreator {
});
customField.id = id;
customField.settings = settings;
});
}
}
createBoard(csvData) {
async createBoard(csvData) {
const boardToCreate = {
archived: false,
color: 'belize',
@@ -212,14 +218,14 @@ export class CsvCreator {
boardToCreate.labels.push(labelToCreate);
}
const boardId = Boards.direct.insert(boardToCreate);
Boards.direct.update(boardId, {
const boardId = await Boards.direct.insertAsync(boardToCreate);
await Boards.direct.updateAsync(boardId, {
$set: {
modifiedAt: this._now(),
},
});
// log activity
Activities.direct.insert({
await Activities.direct.insertAsync({
activityType: 'importBoard',
boardId,
createdAt: this._now(),
@@ -234,7 +240,7 @@ export class CsvCreator {
return boardId;
}
createSwimlanes(boardId) {
async createSwimlanes(boardId) {
const swimlaneToCreate = {
archived: false,
boardId,
@@ -242,8 +248,8 @@ export class CsvCreator {
title: 'Default',
sort: 1,
};
const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate);
Swimlanes.direct.update(swimlaneId, { $set: { updatedAt: this._now() } });
const swimlaneId = await Swimlanes.direct.insertAsync(swimlaneToCreate);
await Swimlanes.direct.updateAsync(swimlaneId, { $set: { updatedAt: this._now() } });
this.swimlane = swimlaneId;
}
@@ -267,10 +273,10 @@ export class CsvCreator {
}
} else listToCreate.title = `Imported List ${this._now()}`;
const listId = Lists.direct.insert(listToCreate);
const listId = await Lists.direct.insertAsync(listToCreate);
this.lists[csvData[i][this.fieldIndex.stage]] = listId;
numOfCreatedLists++;
Lists.direct.update(listId, {
await Lists.direct.updateAsync(listId, {
$set: {
updatedAt: this._now(),
sort: numOfCreatedLists,
@@ -378,7 +384,7 @@ export class CsvCreator {
cardToCreate.customFields = customFields;
});
}
Cards.direct.insert(cardToCreate);
await Cards.direct.insertAsync(cardToCreate);
}
}
@@ -392,10 +398,10 @@ export class CsvCreator {
await currentBoard.archive();
}
this.mapHeadertoCardFieldIndex(board[0]);
const boardId = this.createBoard(board);
const boardId = await this.createBoard(board);
await this.createLists(board, boardId);
this.createSwimlanes(boardId);
this.createCustomFields(boardId);
await this.createSwimlanes(boardId);
await this.createCustomFields(boardId);
await this.createCards(board, boardId);
return boardId;
}
+11 -464
View File
@@ -1,6 +1,8 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Mongo } from 'meteor/mongo';
import Cards from '/models/cards';
const { SimpleSchema } = require('/imports/simpleSchema');
CustomFields = new Mongo.Collection('customFields');
const CustomFields = new Mongo.Collection('customFields');
/**
* A custom field on a card in the board
@@ -11,7 +13,10 @@ CustomFields.attachSchema(
/**
* the ID of the board
*/
type: [String],
type: Array,
},
'boardIds.$': {
type: String,
},
name: {
/**
@@ -48,7 +53,7 @@ CustomFields.attachSchema(
/**
* list of drop down items objects
*/
type: [Object],
type: Array,
optional: true,
},
'settings.dropdownItems.$': {
@@ -126,7 +131,6 @@ CustomFields.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -139,8 +143,8 @@ CustomFields.attachSchema(
}),
);
CustomFields.addToAllCards = cf => {
Cards.update(
CustomFields.addToAllCards = async cf => {
await Cards.updateAsync(
{
boardId: { $in: cf.boardIds },
customFields: { $not: { $elemMatch: { _id: cf._id } } },
@@ -163,461 +167,4 @@ CustomFields.helpers({
},
});
CustomFields.allow({
async insert(userId, doc) {
return allowIsAnyBoardMember(
userId,
await Boards.find({
_id: { $in: doc.boardIds },
}).fetchAsync(),
);
},
async update(userId, doc) {
return allowIsAnyBoardMember(
userId,
await Boards.find({
_id: { $in: doc.boardIds },
}).fetchAsync(),
);
},
async remove(userId, doc) {
return allowIsAnyBoardMember(
userId,
await Boards.find({
_id: { $in: doc.boardIds },
}).fetchAsync(),
);
},
fetch: ['userId', 'boardIds'],
});
// not sure if we need this?
//CustomFields.hookOptions.after.update = { fetchPrevious: false };
function customFieldCreation(userId, doc) {
Activities.insert({
userId,
activityType: 'createCustomField',
boardId: doc.boardIds[0], // We are creating a customField, it has only one boardId
customFieldId: doc._id,
});
}
function customFieldDeletion(userId, doc) {
Activities.insert({
userId,
activityType: 'deleteCustomField',
boardId: doc.boardIds[0], // We are creating a customField, it has only one boardId
customFieldId: doc._id,
});
}
// This has some bug, it does not show edited customField value at Outgoing Webhook,
// instead it shows undefined, and no listId and swimlaneId.
async function customFieldEdit(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
const customFieldValue = (await ReactiveCache.getActivity({ customFieldId: doc._id })).value;
Activities.insert({
userId,
activityType: 'setCustomField',
boardId: doc.boardIds[0], // We are creating a customField, it has only one boardId
customFieldId: doc._id,
customFieldValue,
listId: doc.listId,
swimlaneId: doc.swimlaneId,
});
}
if (Meteor.isServer) {
Meteor.startup(async () => {
await CustomFields._collection.createIndexAsync({ modifiedAt: -1 });
await CustomFields._collection.createIndexAsync({ boardIds: 1 });
});
CustomFields.after.insert((userId, doc) => {
customFieldCreation(userId, doc);
if (doc.alwaysOnCard) {
CustomFields.addToAllCards(doc);
}
});
CustomFields.before.update(async (userId, doc, fieldNames, modifier) => {
if (fieldNames.includes('boardIds') && modifier.$pull) {
Cards.update(
{ boardId: modifier.$pull.boardIds, 'customFields._id': doc._id },
{ $pull: { customFields: { _id: doc._id } } },
{ multi: true },
);
await customFieldEdit(userId, doc);
Activities.remove({
customFieldId: doc._id,
boardId: modifier.$pull.boardIds,
listId: doc.listId,
swimlaneId: doc.swimlaneId,
});
} else if (fieldNames.includes('boardIds') && modifier.$push) {
Activities.insert({
userId,
activityType: 'createCustomField',
boardId: modifier.$push.boardIds,
customFieldId: doc._id,
});
}
});
CustomFields.after.update((userId, doc) => {
if (doc.alwaysOnCard) {
CustomFields.addToAllCards(doc);
}
});
CustomFields.before.remove((userId, doc) => {
customFieldDeletion(userId, doc);
Activities.remove({
customFieldId: doc._id,
});
Cards.update(
{ boardId: { $in: doc.boardIds }, 'customFields._id': doc._id },
{ $pull: { customFields: { _id: doc._id } } },
{ multi: true },
);
});
}
//CUSTOM FIELD REST API
if (Meteor.isServer) {
/**
* @operation get_all_custom_fields
* @summary Get the list of Custom Fields attached to a board
*
* @param {string} boardID the ID of the board
* @return_type [{_id: string,
* name: string,
* type: string}]
*/
JsonRoutes.add('GET', '/api/boards/:boardId/custom-fields', async function(
req,
res,
) {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: (await ReactiveCache.getCustomFields({ boardIds: { $in: [paramBoardId] } })).map(
function(cf) {
return {
_id: cf._id,
name: cf.name,
type: cf.type,
};
},
),
});
});
/**
* @operation get_custom_field
* @summary Get a Custom Fields attached to a board
*
* @param {string} boardID the ID of the board
* @param {string} customFieldId the ID of the custom field
* @return_type [{_id: string,
* boardIds: string}]
*/
JsonRoutes.add(
'GET',
'/api/boards/:boardId/custom-fields/:customFieldId',
async function(req, res) {
const paramBoardId = req.params.boardId;
const paramCustomFieldId = req.params.customFieldId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: await ReactiveCache.getCustomField({
_id: paramCustomFieldId,
boardIds: { $in: [paramBoardId] },
}),
});
},
);
/**
* @operation new_custom_field
* @summary Create a Custom Field
*
* @param {string} boardID the ID of the board
* @param {string} name the name of the custom field
* @param {string} type the type of the custom field
* @param {string} settings the settings object of the custom field
* @param {boolean} showOnCard should we show the custom field on cards?
* @param {boolean} automaticallyOnCard should the custom fields automatically be added on cards?
* @param {boolean} showLabelOnMiniCard should the label of the custom field be shown on minicards?
* @param {boolean} showSumAtTopOfList should the sum of the custom fields be shown at top of list?
* @return_type {_id: string}
*/
JsonRoutes.add('POST', '/api/boards/:boardId/custom-fields', async function(
req,
res,
) {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const board = await ReactiveCache.getBoard(paramBoardId);
const id = CustomFields.direct.insert({
name: req.body.name,
type: req.body.type,
settings: req.body.settings,
showOnCard: req.body.showOnCard,
automaticallyOnCard: req.body.automaticallyOnCard,
showLabelOnMiniCard: req.body.showLabelOnMiniCard,
showSumAtTopOfList: req.body.showSumAtTopOfList,
boardIds: [board._id],
});
const customField = await ReactiveCache.getCustomField({
_id: id,
boardIds: { $in: [paramBoardId] },
});
customFieldCreation(req.body.authorId, customField);
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
});
/**
* @operation edit_custom_field
* @summary Update a Custom Field
*
* @param {string} name the name of the custom field
* @param {string} type the type of the custom field
* @param {string} settings the settings object of the custom field
* @param {boolean} showOnCard should we show the custom field on cards
* @param {boolean} automaticallyOnCard should the custom fields automatically be added on cards
* @param {boolean} showLabelOnMiniCard should the label of the custom field be shown on minicards
* @param {boolean} showSumAtTopOfList should the sum of the custom fields be shown at top of list
* @return_type {_id: string}
*/
JsonRoutes.add(
'PUT',
'/api/boards/:boardId/custom-fields/:customFieldId',
(req, res) => {
const paramBoardId = req.params.boardId;
const paramFieldId = req.params.customFieldId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const boardScopedField = {
_id: paramFieldId,
boardIds: { $in: [paramBoardId] },
};
if (req.body.hasOwnProperty('name')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { name: req.body.name } },
);
}
if (req.body.hasOwnProperty('type')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { type: req.body.type } },
);
}
if (req.body.hasOwnProperty('settings')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { settings: req.body.settings } },
);
}
if (req.body.hasOwnProperty('showOnCard')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { showOnCard: req.body.showOnCard } },
);
}
if (req.body.hasOwnProperty('automaticallyOnCard')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { automaticallyOnCard: req.body.automaticallyOnCard } },
);
}
if (req.body.hasOwnProperty('alwaysOnCard')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { alwaysOnCard: req.body.alwaysOnCard } },
);
}
if (req.body.hasOwnProperty('showLabelOnMiniCard')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { showLabelOnMiniCard: req.body.showLabelOnMiniCard } },
);
}
if (req.body.hasOwnProperty('showSumAtTopOfList')) {
CustomFields.direct.update(
boardScopedField,
{ $set: { showSumAtTopOfList: req.body.showSumAtTopOfList } },
);
}
JsonRoutes.sendResult(res, {
code: 200,
data: { _id: paramFieldId },
});
},
);
/**
* @operation add_custom_field_dropdown_items
* @summary Update a Custom Field's dropdown items
*
* @param {string} [items] names of the custom field
* @return_type {_id: string}
*/
JsonRoutes.add(
'POST',
'/api/boards/:boardId/custom-fields/:customFieldId/dropdown-items',
(req, res) => {
const paramBoardId = req.params.boardId;
const paramCustomFieldId = req.params.customFieldId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const paramItems = req.body.items;
if (req.body.hasOwnProperty('items')) {
if (Array.isArray(paramItems)) {
CustomFields.direct.update(
{
_id: paramCustomFieldId,
boardIds: { $in: [paramBoardId] },
},
{
$push: {
'settings.dropdownItems': {
$each: paramItems
.filter(name => typeof name === 'string')
.map(name => ({
_id: Random.id(6),
name,
})),
},
},
},
);
}
}
JsonRoutes.sendResult(res, {
code: 200,
data: { _id: paramCustomFieldId },
});
},
);
/**
* @operation edit_custom_field_dropdown_item
* @summary Update a Custom Field's dropdown item
*
* @param {string} name names of the custom field
* @return_type {_id: string}
*/
JsonRoutes.add(
'PUT',
'/api/boards/:boardId/custom-fields/:customFieldId/dropdown-items/:dropdownItemId',
(req, res) => {
const paramBoardId = req.params.boardId;
const paramDropdownItemId = req.params.dropdownItemId;
const paramCustomFieldId = req.params.customFieldId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const paramName = req.body.name;
if (req.body.hasOwnProperty('name')) {
CustomFields.direct.update(
{
_id: paramCustomFieldId,
boardIds: { $in: [paramBoardId] },
'settings.dropdownItems._id': paramDropdownItemId,
},
{
$set: {
'settings.dropdownItems.$': {
_id: paramDropdownItemId,
name: paramName,
},
},
},
);
}
JsonRoutes.sendResult(res, {
code: 200,
data: { _id: paramDropdownItemId },
});
},
);
/**
* @operation delete_custom_field_dropdown_item
* @summary Update a Custom Field's dropdown items
*
* @param {string} itemId ID of the dropdown item
* @return_type {_id: string}
*/
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/custom-fields/:customFieldId/dropdown-items/:dropdownItemId',
(req, res) => {
const paramBoardId = req.params.boardId;
const paramCustomFieldId = req.params.customFieldId;
const paramDropdownItemId = req.params.dropdownItemId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
CustomFields.direct.update(
{ _id: paramCustomFieldId, boardIds: { $in: [paramBoardId] } },
{
$pull: {
'settings.dropdownItems': { _id: paramDropdownItemId },
},
},
);
JsonRoutes.sendResult(res, {
code: 200,
data: { _id: paramCustomFieldId },
});
},
);
/**
* @operation delete_custom_field
* @summary Delete a Custom Fields attached to a board
*
* @description The Custom Field can't be retrieved after this operation
*
* @param {string} boardID the ID of the board
* @param {string} customFieldId the ID of the custom field
* @return_type {_id: string}
*/
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/custom-fields/:customFieldId',
function(req, res) {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const id = req.params.customFieldId;
CustomFields.remove({ _id: id, boardIds: { $in: [paramBoardId] } });
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
},
);
}
export default CustomFields;
+19 -18
View File
@@ -1,10 +1,12 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Exporter } from './exporter';
import { Meteor } from 'meteor/meteor';
import ImpersonatedUsers from '/models/impersonatedUsers';
/* global JsonRoutes */
if (Meteor.isServer) {
import { WebApp } from 'meteor/webapp';
const { WebApp } = require('meteor/webapp');
const { sendJsonResult } = require('/server/apiMiddleware');
const { Authentication } = require('/server/authentication');
// todo XXX once we have a real API in place, move that route there
// todo XXX also share the route definition between the client and the server
@@ -27,7 +29,7 @@ if (Meteor.isServer) {
* @param {string} boardId the ID of the board we are exporting
* @param {string} authToken the loginToken
*/
JsonRoutes.add('get', '/api/boards/:boardId/export', async function (req, res) {
WebApp.handlers.get('/api/boards/:boardId/export', async function (req, res) {
const boardId = req.params.boardId;
let user = null;
let impersonateDone = false;
@@ -36,7 +38,7 @@ if (Meteor.isServer) {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
JsonRoutes.sendResult(res, 404);
sendJsonResult(res, 404);
return;
}
@@ -44,7 +46,7 @@ if (Meteor.isServer) {
if (board.isPublic()) {
// Public boards don't require authentication - skip hash operations
const exporter = new Exporter(boardId);
JsonRoutes.sendResult(res, {
sendJsonResult(res, {
code: 200,
data: await exporter.build(),
});
@@ -59,7 +61,7 @@ if (Meteor.isServer) {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
JsonRoutes.sendResult(res, 400);
sendJsonResult(res, 400);
return;
}
@@ -77,21 +79,21 @@ if (Meteor.isServer) {
const exporter = new Exporter(boardId);
if (await exporter.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
reason: 'exportJSON',
});
}
JsonRoutes.sendResult(res, {
sendJsonResult(res, {
code: 200,
data: await exporter.build(),
});
} else {
// we could send an explicit error message, but on the other hand the only
// way to get there is by hacking the UI so let's keep it raw.
JsonRoutes.sendResult(res, 403);
sendJsonResult(res, 403);
}
});
@@ -115,8 +117,7 @@ if (Meteor.isServer) {
* @param {string} attachmentId the ID of the attachment we are exporting
* @param {string} authToken the loginToken
*/
JsonRoutes.add(
'get',
WebApp.handlers.get(
'/api/boards/:boardId/attachments/:attachmentId/export',
async function (req, res) {
const boardId = req.params.boardId;
@@ -128,7 +129,7 @@ if (Meteor.isServer) {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
JsonRoutes.sendResult(res, 404);
sendJsonResult(res, 404);
return;
}
@@ -136,7 +137,7 @@ if (Meteor.isServer) {
if (board.isPublic()) {
// Public boards don't require authentication - skip hash operations
const exporter = new Exporter(boardId, attachmentId);
JsonRoutes.sendResult(res, {
sendJsonResult(res, {
code: 200,
data: await exporter.build(),
});
@@ -151,7 +152,7 @@ if (Meteor.isServer) {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
JsonRoutes.sendResult(res, 400);
sendJsonResult(res, 400);
return;
}
@@ -169,21 +170,21 @@ if (Meteor.isServer) {
const exporter = new Exporter(boardId, attachmentId);
if (await exporter.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
attachmentId: attachmentId,
reason: 'exportJSONattachment',
});
}
JsonRoutes.sendResult(res, {
sendJsonResult(res, {
code: 200,
data: await exporter.build(),
});
} else {
// we could send an explicit error message, but on the other hand the only
// way to get there is by hacking the UI so let's keep it raw.
JsonRoutes.sendResult(res, 403);
sendJsonResult(res, 403);
}
},
);
@@ -276,7 +277,7 @@ if (Meteor.isServer) {
if( params.query.delimiter == "\t" ) {
exportType = 'exportTSV';
}
ImpersonatedUsers.insert({
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
reason: exportType,
+6 -3
View File
@@ -1,13 +1,16 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { runOnServer } from './runOnServer';
import ImpersonatedUsers from '/models/impersonatedUsers';
runOnServer(function() {
// the ExporterExcel class is only available on server and in order to import
// it here we use runOnServer to have it inside a function instead of an
// if (Meteor.isServer) block
import { ExporterExcel } from './server/ExporterExcel';
import { WebApp } from 'meteor/webapp';
const { ExporterExcel } = require('./server/ExporterExcel');
const { WebApp } = require('meteor/webapp');
const { Authentication } = require('/server/authentication');
// todo XXX once we have a real API in place, move that route there
// todo XXX also share the route definition between the client and the server
@@ -85,7 +88,7 @@ runOnServer(function() {
const exporterExcel = new ExporterExcel(boardId, userLanguage);
if (exporterExcel.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
reason: 'exportExcel',
+6 -3
View File
@@ -1,10 +1,13 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { runOnServer } from './runOnServer';
import ImpersonatedUsers from '/models/impersonatedUsers';
runOnServer(function() {
import { ExporterExcelCard, ALL_FIELDS } from './server/ExporterExcelCard';
import { WebApp } from 'meteor/webapp';
const { ExporterExcelCard, ALL_FIELDS } = require('./server/ExporterExcelCard');
const { WebApp } = require('meteor/webapp');
const { Authentication } = require('/server/authentication');
/**
* @operation exportExcelCard
@@ -95,7 +98,7 @@ runOnServer(function() {
const exporter = new ExporterExcelCard(boardId, paramListId, paramCardId, userLanguage, fields, user);
if ((await exporter.canExport(user)) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
await ImpersonatedUsers.insertAsync({
adminId,
boardId,
reason: 'exportExcelCard',
+6 -3
View File
@@ -1,13 +1,16 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { runOnServer } from './runOnServer';
import ImpersonatedUsers from '/models/impersonatedUsers';
runOnServer(function() {
// the ExporterCardPDF class is only available on server and in order to import
// it here we use runOnServer to have it inside a function instead of an
// if (Meteor.isServer) block
import { ExporterCardPDF } from './server/ExporterCardPDF';
import { WebApp } from 'meteor/webapp';
const { ExporterCardPDF } = require('./server/ExporterCardPDF');
const { WebApp } = require('meteor/webapp');
const { Authentication } = require('/server/authentication');
// todo XXX once we have a real API in place, move that route there
// todo XXX also share the route definition between the client and the server
@@ -95,7 +98,7 @@ runOnServer(function() {
);
if (await exporterCardPDF.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
reason: 'exportCardPDF',
+4 -2
View File
@@ -1,4 +1,7 @@
ImpersonatedUsers = new Mongo.Collection('impersonatedUsers');
import { Mongo } from 'meteor/mongo';
const { SimpleSchema } = require('/imports/simpleSchema');
const ImpersonatedUsers = new Mongo.Collection('impersonatedUsers');
/**
* A Impersonated User in wekan
@@ -63,7 +66,6 @@ ImpersonatedUsers.attachSchema(
* modified date of the impersonation
*/
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
+1
View File
@@ -1,3 +1,4 @@
import { Meteor } from 'meteor/meteor';
import { TrelloCreator } from './trelloCreator';
import { WekanCreator } from './wekanCreator';
import { CsvCreator } from './csvCreator';
+8 -315
View File
@@ -1,6 +1,9 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
Integrations = new Mongo.Collection('integrations');
const Integrations = new Mongo.Collection('integrations');
/**
* Integration with third-party applications
@@ -32,9 +35,12 @@ Integrations.attachSchema(
/**
* activities the integration gets triggered (list)
*/
type: [String],
type: Array,
defaultValue: ['all'],
},
'activities.$': {
type: String,
},
url: {
// URL validation with SSRF protection
/**
@@ -94,7 +100,6 @@ Integrations.attachSchema(
* Creation date of the integration
*/
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert) {
@@ -108,7 +113,6 @@ Integrations.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -134,315 +138,4 @@ Integrations.Const = {
return [this.ONEWAY, this.TWOWAY];
},
};
const permissionHelper = {
async allow(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
const isAdmin = user && user.isAdmin;
return isAdmin || allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
};
Integrations.allow({
async insert(userId, doc) {
return await permissionHelper.allow(userId, doc);
},
async update(userId, doc) {
return await permissionHelper.allow(userId, doc);
},
async remove(userId, doc) {
return await permissionHelper.allow(userId, doc);
},
fetch: ['boardId'],
});
//INTEGRATIONS REST API
if (Meteor.isServer) {
Meteor.startup(async () => {
await Integrations._collection.createIndexAsync({ modifiedAt: -1 });
await Integrations._collection.createIndexAsync({ boardId: 1 });
});
/**
* @operation get_all_integrations
* @summary Get all integrations in board
*
* @param {string} boardId the board ID
* @return_type [Integrations]
*/
JsonRoutes.add('GET', '/api/boards/:boardId/integrations', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const data = (await ReactiveCache.getIntegrations(
{ boardId: paramBoardId },
{ fields: { token: 0 } },
)).map(function(doc) {
return doc;
});
JsonRoutes.sendResult(res, { code: 200, data });
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation get_integration
* @summary Get a single integration in board
*
* @param {string} boardId the board ID
* @param {string} intId the integration ID
* @return_type Integrations
*/
JsonRoutes.add('GET', '/api/boards/:boardId/integrations/:intId', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: await ReactiveCache.getIntegration(
{ _id: paramIntId, boardId: paramBoardId },
{ fields: { token: 0 } },
),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation new_integration
* @summary Create a new integration
*
* @param {string} boardId the board ID
* @param {string} url the URL of the integration
* @return_type {_id: string}
*/
JsonRoutes.add('POST', '/api/boards/:boardId/integrations', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
const id = Integrations.insert({
userId: req.userId,
boardId: paramBoardId,
url: req.body.url,
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation edit_integration
* @summary Edit integration data
*
* @param {string} boardId the board ID
* @param {string} intId the integration ID
* @param {string} [enabled] is the integration enabled?
* @param {string} [title] new name of the integration
* @param {string} [url] new URL of the integration
* @param {string} [token] new token of the integration
* @param {string} [activities] new list of activities of the integration
* @return_type {_id: string}
*/
JsonRoutes.add('PUT', '/api/boards/:boardId/integrations/:intId', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (req.body.hasOwnProperty('enabled')) {
const newEnabled = req.body.enabled;
Integrations.direct.update(
{ _id: paramIntId, boardId: paramBoardId },
{ $set: { enabled: newEnabled } },
);
}
if (req.body.hasOwnProperty('title')) {
const newTitle = req.body.title;
Integrations.direct.update(
{ _id: paramIntId, boardId: paramBoardId },
{ $set: { title: newTitle } },
);
}
if (req.body.hasOwnProperty('url')) {
const newUrl = req.body.url;
Integrations.direct.update(
{ _id: paramIntId, boardId: paramBoardId },
{ $set: { url: newUrl } },
);
}
if (req.body.hasOwnProperty('token')) {
const newToken = req.body.token;
Integrations.direct.update(
{ _id: paramIntId, boardId: paramBoardId },
{ $set: { token: newToken } },
);
}
if (req.body.hasOwnProperty('activities')) {
const newActivities = req.body.activities;
Integrations.direct.update(
{ _id: paramIntId, boardId: paramBoardId },
{ $set: { activities: newActivities } },
);
}
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramIntId,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation delete_integration_activities
* @summary Delete subscribed activities
*
* @param {string} boardId the board ID
* @param {string} intId the integration ID
* @param {string} newActivities the activities to remove from the integration
* @return_type Integrations
*/
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/integrations/:intId/activities',
async function(req, res) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
Integrations.direct.update(
{ _id: paramIntId, boardId: paramBoardId },
{ $pullAll: { activities: newActivities } },
);
JsonRoutes.sendResult(res, {
code: 200,
data: await ReactiveCache.getIntegration(
{ _id: paramIntId, boardId: paramBoardId },
{ fields: { _id: 1, activities: 1 } },
),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
},
);
/**
* @operation new_integration_activities
* @summary Add subscribed activities
*
* @param {string} boardId the board ID
* @param {string} intId the integration ID
* @param {string} newActivities the activities to add to the integration
* @return_type Integrations
*/
JsonRoutes.add(
'POST',
'/api/boards/:boardId/integrations/:intId/activities',
async function(req, res) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
Integrations.direct.update(
{ _id: paramIntId, boardId: paramBoardId },
{ $addToSet: { activities: { $each: newActivities } } },
);
JsonRoutes.sendResult(res, {
code: 200,
data: await ReactiveCache.getIntegration(
{ _id: paramIntId, boardId: paramBoardId },
{ fields: { _id: 1, activities: 1 } },
),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
},
);
/**
* @operation delete_integration
* @summary Delete integration
*
* @param {string} boardId the board ID
* @param {string} intId the integration ID
* @return_type {_id: string}
*/
JsonRoutes.add('DELETE', '/api/boards/:boardId/integrations/:intId', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
Integrations.direct.remove({ _id: paramIntId, boardId: paramBoardId });
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramIntId,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
}
export default Integrations;
+7 -13
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
InvitationCodes = new Mongo.Collection('invitation_codes');
const InvitationCodes = new Mongo.Collection('invitation_codes');
InvitationCodes.attachSchema(
new SimpleSchema({
@@ -14,7 +16,6 @@ InvitationCodes.attachSchema(
},
createdAt: {
type: Date,
denyUpdate: false,
optional: true,
// eslint-disable-next-line consistent-return
autoValue() {
@@ -29,7 +30,6 @@ InvitationCodes.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -44,9 +44,12 @@ InvitationCodes.attachSchema(
type: String,
},
boardsToBeInvited: {
type: [String],
type: Array,
optional: true,
},
'boardsToBeInvited.$': {
type: String,
},
valid: {
type: Boolean,
defaultValue: true,
@@ -65,13 +68,4 @@ InvitationCodes.helpers({
// doc.authorId = userId;
// });
if (Meteor.isServer) {
Meteor.startup(async () => {
await InvitationCodes._collection.createIndexAsync({ modifiedAt: -1 });
});
Boards.deny({
fetch: ['members'],
});
}
export default InvitationCodes;
@@ -1,5 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { MongoInternals } from 'meteor/mongo';
import { Mongo, MongoInternals } from 'meteor/mongo';
/**
* Backward compatibility layer for CollectionFS to Meteor-Files migration
+4 -2
View File
@@ -1,8 +1,8 @@
import fs from 'fs';
import Activities from '/models/activities';
import FileStoreStrategy, {FileStoreStrategyFilesystem, FileStoreStrategyGridFs, FileStoreStrategyS3} from './fileStoreStrategy'
const insertActivity = (fileObj, activityType) =>
Activities.insert({
Activities.insertAsync({
userId: fileObj.userId,
type: 'card',
activityType,
@@ -12,6 +12,8 @@ const insertActivity = (fileObj, activityType) =>
cardId: fileObj.meta.cardId,
listId: fileObj.meta.listId,
swimlaneId: fileObj.meta.swimlaneId,
}).catch(error => {
console.error('Failed to insert attachment activity:', error);
});
/** Strategy to store attachments at GridFS (MongoDB) */
+6
View File
@@ -0,0 +1,6 @@
// Storage backend name constants — shared between client and server.
// Extracted from fileStoreStrategy.js to avoid pulling fs/path into the client.
export const STORAGE_NAME_FILESYSTEM = 'fs';
export const STORAGE_NAME_GRIDFS = 'gridfs';
export const STORAGE_NAME_S3 = 's3';
+42 -19
View File
@@ -1,15 +1,20 @@
import fs from 'fs';
import path from 'path';
import { Meteor } from 'meteor/meteor';
import { createObjectId } from './grid/createObjectId';
import { httpStreamOutput } from './httpStream.js';
import {
STORAGE_NAME_FILESYSTEM,
STORAGE_NAME_GRIDFS,
STORAGE_NAME_S3,
} from './fileStoreConstants';
//import {} from './s3/Server-side-file-store.js';
import { ObjectID } from 'bson';
import { ObjectId } from 'bson';
// DISABLED: Minio support removed due to Node.js compatibility issues
// var Minio = require('minio');
export const STORAGE_NAME_FILESYSTEM = "fs";
export const STORAGE_NAME_GRIDFS = "gridfs";
export const STORAGE_NAME_S3 = "s3";
// Re-export constants from shared module (keeps existing import paths working)
export { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from './fileStoreConstants';
/**
* Sanitize filename to prevent path traversal attacks
@@ -246,7 +251,12 @@ export class FileStoreStrategyGridFs extends FileStoreStrategy {
*/
writeStreamFinished(finishedData) {
const gridFsFileIdName = this.getGridFsFileIdName();
Attachments.update({ _id: this.fileObj._id }, { $set: { [gridFsFileIdName]: finishedData._id.toHexString(), } });
Attachments.updateAsync(
{ _id: this.fileObj._id },
{ $set: { [gridFsFileIdName]: finishedData._id.toHexString() } },
).catch(error => {
console.error('Failed to persist GridFS file id:', error);
});
}
/** remove the file */
@@ -261,7 +271,12 @@ export class FileStoreStrategyGridFs extends FileStoreStrategy {
}
const gridFsFileIdName = this.getGridFsFileIdName();
Attachments.update({ _id: this.fileObj._id }, { $unset: { [gridFsFileIdName]: 1 } });
Attachments.updateAsync(
{ _id: this.fileObj._id },
{ $unset: { [gridFsFileIdName]: 1 } },
).catch(error => {
console.error('Failed to clear GridFS file id:', error);
});
}
/** return the storage name
@@ -508,7 +523,9 @@ export const moveToStorage = function(fileObj, storageDestination, fileStoreStra
const safeName = sanitizeFilename(fileObj.name);
if (safeName !== fileObj.name) {
// Update the database with the sanitized name
Attachments.update({ _id: fileObj._id }, { $set: { name: safeName } });
Attachments.updateAsync({ _id: fileObj._id }, { $set: { name: safeName } }).catch(error => {
console.error('Failed to persist sanitized attachment name:', error);
});
// Update the local object for use in this function
fileObj.name = safeName;
}
@@ -531,18 +548,20 @@ export const moveToStorage = function(fileObj, storageDestination, fileStoreStra
console.error('[readStream error]: ', error, fileObj._id);
});
writeStream.on('finish', Meteor.bindEnvironment((finishedData) => {
writeStream.on('finish', finishedData => {
strategyWrite.writeStreamFinished(finishedData);
}));
});
// https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
readStream.on('end', Meteor.bindEnvironment(() => {
Attachments.update({ _id: fileObj._id }, { $set: {
readStream.on('end', () => {
Attachments.updateAsync({ _id: fileObj._id }, { $set: {
[`versions.${versionName}.storage`]: strategyWrite.getStorageName(),
[`versions.${versionName}.path`]: filePath,
} });
} }).catch(error => {
console.error('Failed to update attachment storage metadata:', error);
});
strategyRead.unlink();
}));
});
readStream.pipe(writeStream);
}
@@ -569,8 +588,8 @@ export const copyFile = async function(fileObj, newCardId, fileStoreStrategyFact
});
// https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
readStream.on('end', Meteor.bindEnvironment(() => {
const fileId = new ObjectID().toString();
readStream.on('end', () => {
const fileId = new ObjectId().toString();
Attachments.addFile(
tempPath,
{
@@ -594,12 +613,14 @@ export const copyFile = async function(fileObj, newCardId, fileStoreStrategyFact
console.log(err);
} else {
// Set the userId again
Attachments.update({ _id: fileRef._id }, { $set: { userId: fileObj.userId } });
Attachments.updateAsync({ _id: fileRef._id }, { $set: { userId: fileObj.userId } }).catch(error => {
console.error('Failed to update copied attachment userId:', error);
});
}
},
true,
);
}));
});
readStream.pipe(writeStream);
});
@@ -614,9 +635,11 @@ export const rename = function(fileObj, newName, fileStoreStrategyFactory) {
const newFilePath = strategy.getNewPath(fileStoreStrategyFactory.storagePath, safeName);
strategy.rename(newFilePath);
Attachments.update({ _id: fileObj._id }, { $set: {
Attachments.updateAsync({ _id: fileObj._id }, { $set: {
"name": safeName,
[`versions.${versionName}.path`]: newFilePath,
} });
} }).catch(error => {
console.error('Failed to persist renamed attachment path:', error);
});
});
};
+7 -5
View File
@@ -27,7 +27,7 @@ export const createOnAfterUpload = bucket =>
// that occurred during the upload to prevent zombie files
.on('error', err => {
// console.error("[createOnAfterUpload error]", err);
self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS
self.unlink(file, versionName); // Unlink files from FS
})
// once we are finished, we attach the gridFS Object id on the
@@ -35,17 +35,19 @@ export const createOnAfterUpload = bucket =>
// upload file from the filesystem
.on(
'finish',
Meteor.bindEnvironment(ver => {
ver => {
const property = `versions.${versionName}.meta.gridFsFileId`;
self.collection.update(file._id, {
self.collection.updateAsync(file._id, {
$set: {
[property]: ver._id.toHexString(),
},
}).catch(err => {
console.error('[createOnAfterUpload update error]', err);
});
self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS
}),
self.unlink(file, versionName); // Unlink files from FS
},
);
});
};
+1
View File
@@ -1,4 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { DDP } from 'meteor/ddp';
import { mongodbConnectionManager } from './mongodbConnectionManager';
import { mongodbDriverManager } from './mongodbDriverManager';
+40 -863
View File
@@ -1,8 +1,13 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
import { ALLOWED_COLORS } from '/config/const';
import { LIST_COLORS } from '/models/metadata/colors';
import PositionHistory from './positionHistory';
import Boards from '/models/boards';
import Cards from '/models/cards';
const { SimpleSchema } = require('/imports/simpleSchema');
Lists = new Mongo.Collection('lists');
const Lists = new Mongo.Collection('lists');
/**
* A list (column) in the Wekan board.
@@ -78,7 +83,6 @@ Lists.attachSchema(
* is the list sorted
*/
type: Number,
decimal: true,
// XXX We should probably provide a default
optional: true,
},
@@ -99,7 +103,6 @@ Lists.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
// this is redundant with updatedAt
@@ -125,7 +128,6 @@ Lists.attachSchema(
* value of the WIP
*/
type: Number,
decimal: false,
defaultValue: 1,
},
'wipLimit.enabled': {
@@ -149,7 +151,7 @@ Lists.attachSchema(
type: String,
optional: true,
// silver is the default
allowedValues: ALLOWED_COLORS,
allowedValues: LIST_COLORS,
},
type: {
/**
@@ -179,22 +181,6 @@ Lists.attachSchema(
}),
);
Lists.allow({
async insert(userId, doc) {
// ReadOnly and CommentOnly users cannot create lists
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
},
async update(userId, doc) {
// ReadOnly and CommentOnly users cannot edit lists
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
},
async remove(userId, doc) {
// ReadOnly and CommentOnly users cannot delete lists
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
},
fetch: ['boardId'],
});
Lists.helpers({
async copy(boardId, swimlaneId) {
const oldId = this._id;
@@ -424,868 +410,59 @@ Lists.archivedListIds = async () => {
});
};
const hasBoardWriteAccess = (userId, board) => {
if (!userId || !board) {
return false;
}
if (typeof allowIsBoardMemberWithWriteAccess === 'function') {
return allowIsBoardMemberWithWriteAccess(userId, board);
}
// Fallback for client method simulation where server-only globals are unavailable.
return (
board.hasMember(userId) &&
!board.hasNoComments(userId) &&
!board.hasCommentOnly(userId) &&
!board.hasWorker(userId) &&
!board.hasReadOnly(userId) &&
!board.hasReadAssignedOnly(userId)
);
};
Meteor.methods({
async createListAfter(params) {
check(params, {
title: String,
boardId: String,
swimlaneId: Match.OneOf(String, null, undefined),
afterListId: Match.OneOf(String, null, undefined),
nextListId: Match.OneOf(String, null, undefined),
type: Match.Maybe(String),
});
const {
title,
boardId,
swimlaneId = null,
afterListId = null,
nextListId = null,
type = 'list',
} = params;
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
// Avoid client-side stub writes; server method call is authoritative.
if (Meteor.isClient && this.isSimulation) {
return null;
}
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
if (Meteor.isServer) {
try {
if (typeof Authentication !== 'undefined' && Authentication?.checkBoardWriteAccess) {
await Authentication.checkBoardWriteAccess(this.userId, boardId);
} else if (!hasBoardWriteAccess(this.userId, board)) {
throw new Meteor.Error('not-authorized', 'Access denied');
}
} catch (error) {
throw new Meteor.Error('not-authorized', 'Access denied');
}
} else if (!hasBoardWriteAccess(this.userId, board)) {
throw new Meteor.Error('not-authorized', 'Access denied');
}
const normalizeSwimlaneId = value => value || '';
const getResolvedSwimlaneId = (list, fallback = '') => {
if (!list) return normalizeSwimlaneId(fallback);
if (typeof list.getEffectiveSwimlaneId === 'function') {
return normalizeSwimlaneId(list.getEffectiveSwimlaneId() || fallback);
}
return normalizeSwimlaneId(list.swimlaneId || fallback);
};
const defaultSwimlane = board.getDefaultSwimline ? board.getDefaultSwimline() : null;
let targetSwimlaneId = normalizeSwimlaneId(swimlaneId || (defaultSwimlane && defaultSwimlane._id));
let sort = 0;
if (afterListId) {
const selectedList = await ReactiveCache.getList({
_id: afterListId,
boardId,
archived: false,
});
if (!selectedList) {
throw new Meteor.Error('list-not-found', 'Selected list not found');
}
targetSwimlaneId = getResolvedSwimlaneId(selectedList, targetSwimlaneId);
const swimlaneLists = (await ReactiveCache.getLists({
boardId,
archived: false,
}))
.filter(list => getResolvedSwimlaneId(list, targetSwimlaneId) === targetSwimlaneId)
.sort((a, b) => a.sort - b.sort);
let nextList = null;
if (nextListId) {
nextList = await ReactiveCache.getList({
_id: nextListId,
boardId,
archived: false,
});
if (nextList) {
const nextSwimlaneId = getResolvedSwimlaneId(nextList, targetSwimlaneId);
if (nextSwimlaneId !== targetSwimlaneId) {
nextList = null;
}
}
}
if (!nextList) {
const selectedIndex = swimlaneLists.findIndex(list => list._id === selectedList._id);
nextList = selectedIndex >= 0 ? swimlaneLists[selectedIndex + 1] : null;
}
if (
nextList &&
Number.isFinite(nextList.sort) &&
Number.isFinite(selectedList.sort) &&
nextList.sort > selectedList.sort
) {
sort = selectedList.sort + (nextList.sort - selectedList.sort) / 2;
} else if (Number.isFinite(selectedList.sort)) {
sort = selectedList.sort + 1;
} else {
const last = swimlaneLists[swimlaneLists.length - 1];
sort = Number.isFinite(last?.sort) ? last.sort + 1 : 0;
}
} else {
const swimlaneLists = (await ReactiveCache.getLists({
boardId,
archived: false,
}))
.filter(list => getResolvedSwimlaneId(list, targetSwimlaneId) === targetSwimlaneId)
.sort((a, b) => a.sort - b.sort);
const last = swimlaneLists[swimlaneLists.length - 1];
sort = Number.isFinite(last?.sort) ? last.sort + 1 : 0;
}
const listId = await Lists.insertAsync({
title,
boardId,
sort,
type,
swimlaneId: targetSwimlaneId,
});
return listId;
},
async copyList(listId, boardId, swimlaneId, title, neighborListId, position) {
check(listId, String);
check(boardId, String);
check(swimlaneId, String);
check(title, String);
check(neighborListId, Match.OneOf(String, null, undefined));
check(position, Match.OneOf(String, null, undefined));
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
if (Meteor.isClient) return null;
const list = await ReactiveCache.getList(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const targetBoard = await ReactiveCache.getBoard(boardId);
if (!targetBoard) {
throw new Meteor.Error('board-not-found', 'Target board not found');
}
// Compute sort value relative to neighbor list
let sort = (await ReactiveCache.getLists({ boardId, archived: false })).length;
if (neighborListId) {
const neighborList = await ReactiveCache.getList({ _id: neighborListId, boardId, archived: false });
if (neighborList && Number.isFinite(neighborList.sort)) {
const allLists = (await ReactiveCache.getLists({ boardId, swimlaneId, archived: false }))
.sort((a, b) => a.sort - b.sort);
const neighborIndex = allLists.findIndex(l => l._id === neighborListId);
if (position === 'left') {
const prev = allLists[neighborIndex - 1];
sort = prev && Number.isFinite(prev.sort)
? (prev.sort + neighborList.sort) / 2
: neighborList.sort - 1;
} else {
const next = allLists[neighborIndex + 1];
sort = next && Number.isFinite(next.sort)
? (neighborList.sort + next.sort) / 2
: neighborList.sort + 1;
}
}
}
const newListId = await Lists.insertAsync({
title: title || list.title,
boardId,
swimlaneId,
type: list.type,
archived: false,
wipLimit: list.wipLimit,
sort,
});
const cards = await ReactiveCache.getCards({ listId: list._id, archived: false });
for (const card of cards) {
await card.copy(boardId, swimlaneId, newListId);
}
return newListId;
},
async moveList(listId, boardId, swimlaneId, neighborListId, position, title) {
check(listId, String);
check(boardId, String);
check(swimlaneId, String);
check(neighborListId, Match.OneOf(String, null, undefined));
check(position, Match.OneOf(String, null, undefined));
check(title, Match.OneOf(String, null, undefined));
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
if (Meteor.isClient) return null;
const list = await ReactiveCache.getList(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const desiredTitle = typeof title === 'string' && title.trim().length > 0
? title.trim()
: list.title;
const targetBoard = await ReactiveCache.getBoard(boardId);
if (!targetBoard) {
throw new Meteor.Error('board-not-found', 'Target board not found');
}
// list.move() relies on this.title to determine destination list identity.
list.title = desiredTitle;
await list.move(boardId, swimlaneId);
// Update sort of resulting list when a neighbor is specified
if (neighborListId) {
const movedList = await ReactiveCache.getList({ boardId, title: desiredTitle, archived: false });
const neighborList = await ReactiveCache.getList({ _id: neighborListId, boardId, archived: false });
if (movedList && neighborList && Number.isFinite(neighborList.sort)) {
const allLists = (await ReactiveCache.getLists({ boardId, swimlaneId, archived: false }))
.filter(l => l._id !== movedList._id)
.sort((a, b) => a.sort - b.sort);
const neighborIndex = allLists.findIndex(l => l._id === neighborListId);
let newSort;
if (position === 'left') {
const prev = allLists[neighborIndex - 1];
newSort = prev && Number.isFinite(prev.sort)
? (prev.sort + neighborList.sort) / 2
: neighborList.sort - 1;
} else {
const next = allLists[neighborIndex + 1];
newSort = next && Number.isFinite(next.sort)
? (neighborList.sort + next.sort) / 2
: neighborList.sort + 1;
}
await Lists.updateAsync(movedList._id, { $set: { sort: newSort } });
}
}
await list.archive();
},
async applyWipLimit(listId, limit) {
check(listId, String);
check(limit, Number);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = await ReactiveCache.getList(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = await ReactiveCache.getBoard(list.boardId);
if (!board || !board.hasAdmin(this.userId)) {
throw new Meteor.Error('not-authorized', 'You must be a board admin to modify WIP limits.');
}
if (limit === 0) {
limit = 1;
}
await list.setWipLimit(limit);
},
async enableWipLimit(listId) {
check(listId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = await ReactiveCache.getList(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = await ReactiveCache.getBoard(list.boardId);
if (!board || !board.hasAdmin(this.userId)) {
throw new Meteor.Error('not-authorized', 'You must be a board admin to modify WIP limits.');
}
if ((await list.getWipLimit('value')) === 0) {
await list.setWipLimit(1);
}
await list.toggleWipLimit(!(await list.getWipLimit('enabled')));
},
async enableSoftLimit(listId) {
check(listId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = await ReactiveCache.getList(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = await ReactiveCache.getBoard(list.boardId);
if (!board || !board.hasAdmin(this.userId)) {
throw new Meteor.Error('not-authorized', 'You must be a board admin to modify WIP limits.');
}
await list.toggleSoftLimit(!(await list.getWipLimit('soft')));
},
async myLists() {
// my lists
const lists = await ReactiveCache.getLists(
{
boardId: { $in: await Boards.userBoardIds(this.userId) },
archived: false,
},
{
fields: { title: 1 },
},
);
return [...new Set(lists.map(list => list.title))].sort();
},
async updateListSort(listId, boardId, updateData) {
check(listId, String);
check(boardId, String);
check(updateData, Object);
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
if (Meteor.isServer) {
if (typeof allowIsBoardMember === 'function') {
if (!allowIsBoardMember(this.userId, board)) {
throw new Meteor.Error('permission-denied', 'User does not have permission to modify this board');
}
}
}
const list = await ReactiveCache.getList(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const validUpdateFields = ['sort', 'swimlaneId', 'updatedAt', 'modifiedAt'];
Object.keys(updateData).forEach(field => {
if (!validUpdateFields.includes(field)) {
throw new Meteor.Error('invalid-field', `Field ${field} is not allowed`);
}
});
if (updateData.swimlaneId) {
const swimlane = await ReactiveCache.getSwimlane(updateData.swimlaneId);
if (!swimlane || swimlane.boardId !== boardId) {
throw new Meteor.Error('invalid-swimlane', 'Invalid swimlane for this board');
}
}
await Lists.updateAsync(
listId,
{
$set: {
...updateData,
modifiedAt: new Date(),
},
},
);
return {
success: true,
listId,
updatedFields: Object.keys(updateData),
timestamp: new Date().toISOString(),
};
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await Lists._collection.rawCollection().createIndex({ modifiedAt: -1 });
await Lists._collection.rawCollection().createIndex({ boardId: 1 });
await Lists._collection.rawCollection().createIndex({ archivedAt: -1 });
});
}
Lists.after.insert((userId, doc) => {
if (Meteor.isClient) {
return;
}
Activities.insert({
userId,
type: 'list',
activityType: 'createList',
boardId: doc.boardId,
listId: doc._id,
// this preserves the name so that the activity can be useful after the
// list is deleted
title: doc.title,
});
// Track original position for new lists
Meteor.setTimeout(() => {
const list = Lists.findOne(doc._id);
if (list) {
list.trackOriginalPosition();
}
}, 100);
});
Lists.before.remove(async (userId, doc) => {
if (Meteor.isClient) {
return;
}
const cards = await ReactiveCache.getCards({ listId: doc._id });
if (cards) {
for (const card of cards) {
// For non-archived lists, preserve existing behavior: deleting the list
// deletes all its cards.
if (!doc.archived) {
await Cards.removeAsync(card._id);
continue;
}
// For archived lists, only delete cards that were archived together with
// the list (or after it). Keep cards that were archived before the list
// was archived so deleting the list does not remove independently
// archived cards.
const listArchivedAt = doc.archivedAt;
const cardArchivedAt = card.archivedAt;
const shouldDeleteCard =
!listArchivedAt ||
!card.archived ||
!cardArchivedAt ||
cardArchivedAt >= listArchivedAt;
if (shouldDeleteCard) {
await Cards.removeAsync(card._id);
}
}
}
await Activities.insertAsync({
userId,
type: 'list',
activityType: 'removeList',
boardId: doc.boardId,
listId: doc._id,
title: doc.title,
});
});
// Ensure we don't fetch previous doc in after.update hook
Lists.hookOptions.after.update = { fetchPrevious: false };
Lists.after.update((userId, doc, fieldNames) => {
if (Meteor.isClient) {
return;
}
if (fieldNames.includes('title')) {
Activities.insert({
userId,
type: 'list',
activityType: 'changedListTitle',
listId: doc._id,
boardId: doc.boardId,
// this preserves the name so that the activity can be useful after the
// list is deleted
title: doc.title,
});
} else if (doc.archived) {
Activities.insert({
userId,
type: 'list',
activityType: 'archivedList',
listId: doc._id,
boardId: doc.boardId,
// this preserves the name so that the activity can be useful after the
// list is deleted
title: doc.title,
});
} else if (fieldNames.includes('archived')) {
Activities.insert({
userId,
type: 'list',
activityType: 'restoredList',
listId: doc._id,
boardId: doc.boardId,
// this preserves the name so that the activity can be useful after the
// list is deleted
title: doc.title,
});
}
// When sort or swimlaneId change, trigger a pub/sub refresh marker
if (fieldNames.includes('sort') || fieldNames.includes('swimlaneId')) {
Lists.direct.update(
{ _id: doc._id },
{ $set: { _updatedAt: new Date() } },
);
}
});
//LISTS REST API
if (Meteor.isServer) {
/**
* @operation get_all_lists
* @summary Get the list of Lists attached to a board
*
* @param {string} boardId the board ID
* @return_type [{_id: string,
* title: string}]
*/
JsonRoutes.add('GET', '/api/boards/:boardId/lists', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: (await ReactiveCache.getLists({ boardId: paramBoardId, archived: false })).map(
function(doc) {
return {
_id: doc._id,
title: doc.title,
};
},
),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation get_list
* @summary Get a List attached to a board
*
* @param {string} boardId the board ID
* @param {string} listId the List ID
* @return_type Lists
*/
JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramListId = req.params.listId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: await ReactiveCache.getList({
_id: paramListId,
boardId: paramBoardId,
archived: false,
}),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation new_list
* @summary Add a List to a board
*
* @param {string} boardId the board ID
* @param {string} title the title of the List
* @return_type {_id: string}
*/
JsonRoutes.add('POST', '/api/boards/:boardId/lists', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
const board = await ReactiveCache.getBoard(paramBoardId);
const id = await Lists.insertAsync({
title: req.body.title,
boardId: paramBoardId,
sort: board.lists().length,
swimlaneId: req.body.swimlaneId || board.getDefaultSwimline()._id, // Use provided swimlaneId or default
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation edit_list
* @summary Edit a List
*
* @description This updates a list on a board.
* You can update the title, color, wipLimit, starred, and collapsed properties.
*
* @param {string} boardId the board ID
* @param {string} listId the ID of the list to update
* @param {string} [title] the new title of the list
* @param {string} [color] the new color of the list
* @param {Object} [wipLimit] the WIP limit configuration
* @param {boolean} [starred] whether the list is starred
* @param {boolean} [collapsed] whether the list is collapsed
* @return_type {_id: string}
*/
JsonRoutes.add('PUT', '/api/boards/:boardId/lists/:listId', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramListId = req.params.listId;
let updated = false;
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
const list = await ReactiveCache.getList({
_id: paramListId,
boardId: paramBoardId,
archived: false,
});
if (!list) {
JsonRoutes.sendResult(res, {
code: 404,
data: { error: 'List not found' },
});
return;
}
// Update title if provided
if (req.body.title) {
// Basic client-side validation - server will handle full sanitization
const newTitle = req.body.title.length > 1000 ? req.body.title.substring(0, 1000) : req.body.title;
if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
console.warn('Sanitized list title input:', req.body.title, '->', newTitle);
}
Lists.direct.update(
{
_id: paramListId,
boardId: paramBoardId,
archived: false,
},
{
$set: {
title: newTitle,
},
},
);
updated = true;
}
// Update color if provided
if (req.body.color) {
const newColor = req.body.color;
Lists.direct.update(
{
_id: paramListId,
boardId: paramBoardId,
archived: false,
},
{
$set: {
color: newColor,
},
},
);
updated = true;
}
// Update starred status if provided
if (req.body.hasOwnProperty('starred')) {
const newStarred = req.body.starred;
Lists.direct.update(
{
_id: paramListId,
boardId: paramBoardId,
archived: false,
},
{
$set: {
starred: newStarred,
},
},
);
updated = true;
}
// NOTE: collapsed state removed from board-level
// It's per-user only - use user profile methods instead
// Update wipLimit if provided
if (req.body.wipLimit) {
const newWipLimit = req.body.wipLimit;
Lists.direct.update(
{
_id: paramListId,
boardId: paramBoardId,
archived: false,
},
{
$set: {
wipLimit: newWipLimit,
},
},
);
updated = true;
}
// Check if update is true or false
if (!updated) {
JsonRoutes.sendResult(res, {
code: 404,
data: {
message: 'Error',
},
});
return;
}
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramListId,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation delete_list
* @summary Delete a List
*
* @description This **deletes** a list from a board.
* The list is not put in the recycle bin.
*
* @param {string} boardId the board ID
* @param {string} listId the ID of the list to remove
* @return_type {_id: string}
*/
JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId', function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramListId = req.params.listId;
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
Lists.remove({ _id: paramListId, boardId: paramBoardId });
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramListId,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
}
// Position history tracking methods
Lists.helpers({
/**
* Track the original position of this list
*/
trackOriginalPosition() {
const existingHistory = PositionHistory.findOne({
const selector = {
boardId: this.boardId,
entityType: 'list',
entityId: this._id,
});
};
const document = {
boardId: this.boardId,
entityType: 'list',
entityId: this._id,
originalPosition: {
sort: this.sort,
title: this.title,
},
originalSwimlaneId: this.swimlaneId || null,
originalTitle: this.title,
createdAt: new Date(),
updatedAt: new Date(),
};
if (!existingHistory) {
PositionHistory.insert({
boardId: this.boardId,
entityType: 'list',
entityId: this._id,
originalPosition: {
sort: this.sort,
title: this.title,
},
originalSwimlaneId: this.swimlaneId || null,
originalTitle: this.title,
createdAt: new Date(),
updatedAt: new Date(),
if (Meteor.isServer) {
return PositionHistory.findOneAsync(selector).then(existingHistory => {
if (!existingHistory) {
return PositionHistory.insertAsync(document);
}
return existingHistory;
});
}
const existingHistory = PositionHistory.findOne(selector);
if (!existingHistory) {
PositionHistory.insert(document);
}
},
/**
* Get the original position history for this list
*/
getOriginalPosition() {
return PositionHistory.findOne({
const selector = {
boardId: this.boardId,
entityType: 'list',
entityId: this._id,
});
};
if (Meteor.isServer) {
return PositionHistory.findOneAsync(selector);
}
return PositionHistory.findOne(selector);
},
/**
+3 -94
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
LockoutSettings = new Mongo.Collection('lockoutSettings');
const LockoutSettings = new Mongo.Collection('lockoutSettings');
LockoutSettings.attachSchema(
new SimpleSchema({
@@ -9,14 +11,12 @@ LockoutSettings.attachSchema(
},
value: {
type: Number,
decimal: false,
},
category: {
type: String,
},
sort: {
type: Number,
decimal: true,
},
createdAt: {
type: Date,
@@ -34,7 +34,6 @@ LockoutSettings.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -47,96 +46,6 @@ LockoutSettings.attachSchema(
}),
);
LockoutSettings.allow({
async update(userId) {
const user = await Meteor.users.findOneAsync(userId);
return user && user.isAdmin;
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await LockoutSettings._collection.createIndexAsync({ modifiedAt: -1 });
// Known users settings
await LockoutSettings.upsertAsync(
{ _id: 'known-failuresBeforeLockout' },
{
$setOnInsert: {
value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE
? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE, 10) : 3,
category: 'known',
sort: 0,
},
},
);
await LockoutSettings.upsertAsync(
{ _id: 'known-lockoutPeriod' },
{
$setOnInsert: {
value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD
? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD, 10) : 60,
category: 'known',
sort: 1,
},
},
);
await LockoutSettings.upsertAsync(
{ _id: 'known-failureWindow' },
{
$setOnInsert: {
value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW
? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW, 10) : 15,
category: 'known',
sort: 2,
},
},
);
// Unknown users settings
const typoVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE;
const correctVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BEFORE;
await LockoutSettings.upsertAsync(
{ _id: 'unknown-failuresBeforeLockout' },
{
$setOnInsert: {
value: (correctVar || typoVar)
? parseInt(correctVar || typoVar, 10) : 3,
category: 'unknown',
sort: 0,
},
},
);
await LockoutSettings.upsertAsync(
{ _id: 'unknown-lockoutPeriod' },
{
$setOnInsert: {
value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD
? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD, 10) : 60,
category: 'unknown',
sort: 1,
},
},
);
await LockoutSettings.upsertAsync(
{ _id: 'unknown-failureWindow' },
{
$setOnInsert: {
value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW
? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW, 10) : 15,
category: 'unknown',
sort: 2,
},
},
);
});
}
LockoutSettings.helpers({
getKnownConfig() {
// Fetch all settings in one query instead of 3 separate queries
+16
View File
@@ -0,0 +1,16 @@
import { ALLOWED_BOARD_COLORS, ALLOWED_COLORS } from '/config/const';
const LABEL_COLORS = [...ALLOWED_COLORS];
const LIST_COLORS = [...ALLOWED_COLORS];
const CARD_COLORS = [...ALLOWED_COLORS];
const SWIMLANE_COLORS = [...ALLOWED_COLORS];
const BOARD_COLORS = [...ALLOWED_BOARD_COLORS];
export {
BOARD_COLORS,
CARD_COLORS,
LABEL_COLORS,
LIST_COLORS,
SWIMLANE_COLORS,
};
+3 -224
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
Org = new Mongo.Collection('org');
const Org = new Mongo.Collection('org');
/**
* A Organization in Wekan. A Enterprise in Trello.
@@ -58,7 +60,6 @@ Org.attachSchema(
* creation date of the organization
*/
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert) {
@@ -72,7 +73,6 @@ Org.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -85,225 +85,4 @@ Org.attachSchema(
}),
);
if (Meteor.isServer) {
Org.allow({
async insert(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
async update(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
async remove(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
fetch: [],
});
Meteor.methods({
async setCreateOrg(
orgDisplayName,
orgDesc,
orgShortName,
orgAutoAddUsersWithDomainName,
orgWebsite,
orgIsActive,
) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(orgDisplayName, String);
check(orgDesc, String);
check(orgShortName, String);
check(orgAutoAddUsersWithDomainName, String);
check(orgWebsite, String);
check(orgIsActive, Boolean);
const nOrgNames = (await ReactiveCache.getOrgs({ orgShortName })).length;
if (nOrgNames > 0) {
throw new Meteor.Error('orgname-already-taken');
} else {
await Org.insertAsync({
orgDisplayName,
orgDesc,
orgShortName,
orgAutoAddUsersWithDomainName,
orgWebsite,
orgIsActive,
});
}
}
},
async setCreateOrgFromOidc(
orgDisplayName,
orgDesc,
orgShortName,
orgAutoAddUsersWithDomainName,
orgWebsite,
orgIsActive,
) {
check(orgDisplayName, String);
check(orgDesc, String);
check(orgShortName, String);
check(orgAutoAddUsersWithDomainName, String);
check(orgWebsite, String);
check(orgIsActive, Boolean);
const nOrgNames = (await ReactiveCache.getOrgs({ orgShortName })).length;
if (nOrgNames > 0) {
throw new Meteor.Error('orgname-already-taken');
} else {
await Org.insertAsync({
orgDisplayName,
orgDesc,
orgShortName,
orgAutoAddUsersWithDomainName,
orgWebsite,
orgIsActive,
});
}
},
async setOrgDisplayName(org, orgDisplayName) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(org, Object);
check(orgDisplayName, String);
Org.update(org, {
$set: { orgDisplayName: orgDisplayName },
});
await Meteor.callAsync('setUsersOrgsOrgDisplayName', org._id, orgDisplayName);
}
},
async setOrgDesc(org, orgDesc) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(org, Object);
check(orgDesc, String);
Org.update(org, {
$set: { orgDesc: orgDesc },
});
}
},
async setOrgShortName(org, orgShortName) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(org, Object);
check(orgShortName, String);
Org.update(org, {
$set: { orgShortName: orgShortName },
});
}
},
async setAutoAddUsersWithDomainName(org, orgAutoAddUsersWithDomainName) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(org, Object);
check(orgAutoAddUsersWithDomainName, String);
Org.update(org, {
$set: { orgAutoAddUsersWithDomainName: orgAutoAddUsersWithDomainName },
});
}
},
async setOrgIsActive(org, orgIsActive) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(org, Object);
check(orgIsActive, Boolean);
Org.update(org, {
$set: { orgIsActive: orgIsActive },
});
}
},
async setOrgAllFieldsFromOidc(
org,
orgDisplayName,
orgDesc,
orgShortName,
orgAutoAddUsersWithDomainName,
orgWebsite,
orgIsActive,
) {
check(org, Object);
check(orgDisplayName, String);
check(orgDesc, String);
check(orgShortName, String);
check(orgAutoAddUsersWithDomainName, String);
check(orgWebsite, String);
check(orgIsActive, Boolean);
Org.update(org, {
$set: {
orgDisplayName: orgDisplayName,
orgDesc: orgDesc,
orgShortName: orgShortName,
orgAutoAddUsersWithDomainName: orgAutoAddUsersWithDomainName,
orgWebsite: orgWebsite,
orgIsActive: orgIsActive,
},
});
await Meteor.callAsync('setUsersOrgsOrgDisplayName', org._id, orgDisplayName);
},
async setOrgAllFields(
org,
orgDisplayName,
orgDesc,
orgShortName,
orgAutoAddUsersWithDomainName,
orgWebsite,
orgIsActive,
) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(org, Object);
check(orgDisplayName, String);
check(orgDesc, String);
check(orgShortName, String);
check(orgAutoAddUsersWithDomainName, String);
check(orgWebsite, String);
check(orgIsActive, Boolean);
Org.update(org, {
$set: {
orgDisplayName: orgDisplayName,
orgDesc: orgDesc,
orgShortName: orgShortName,
orgAutoAddUsersWithDomainName: orgAutoAddUsersWithDomainName,
orgWebsite: orgWebsite,
orgIsActive: orgIsActive,
},
});
await Meteor.callAsync('setUsersOrgsOrgDisplayName', org._id, orgDisplayName);
}
},
async getOrgsCollectionCount(query = {}) {
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
throw new Meteor.Error('not-authorized');
}
check(query, Match.OneOf(Object, null, undefined));
const count = await (await ReactiveCache.getOrgs(query, {}, true)).count();
return count;
},
});
}
if (Meteor.isServer) {
// Index for Organization name.
Meteor.startup(async () => {
// Org._collection.createIndexAsync({ name: -1 });
await Org._collection.createIndexAsync({ orgDisplayName: 1 });
});
}
export default Org;
+3 -10
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { incrementCounter } from './counters';
const { SimpleSchema } = require('/imports/simpleSchema');
OrgUser = new Mongo.Collection('orgUser');
const OrgUser = new Mongo.Collection('orgUser');
/**
* A Organization User in wekan
@@ -60,7 +62,6 @@ OrgUser.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -73,12 +74,4 @@ OrgUser.attachSchema(
}),
);
if (Meteor.isServer) {
// Index for Organization User.
Meteor.startup(async () => {
await OrgUser._collection.createIndexAsync({ orgId: -1 });
await OrgUser._collection.createIndexAsync({ orgId: -1, userId: -1 });
});
}
export default OrgUser;
+3 -9
View File
@@ -1,10 +1,12 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
/**
* PositionHistory collection to track original positions of swimlanes, lists, and cards
* before the list naming feature was added in commit 719ef87efceacfe91461a8eeca7cf74d11f4cc0a
*/
PositionHistory = new Mongo.Collection('positionHistory');
const PositionHistory = new Mongo.Collection('positionHistory');
PositionHistory.attachSchema(
new SimpleSchema({
@@ -159,12 +161,4 @@ PositionHistory.helpers({
}
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await PositionHistory._collection.createIndexAsync({ boardId: 1, entityType: 1, entityId: 1 });
await PositionHistory._collection.createIndexAsync({ boardId: 1, entityType: 1 });
await PositionHistory._collection.createIndexAsync({ createdAt: -1 });
});
}
export default PositionHistory;
+4 -13
View File
@@ -1,15 +1,6 @@
import { Mongo } from 'meteor/mongo';
// Define presences collection
Presences = new Mongo.Collection('presences');
const Presences = new Mongo.Collection('presences');
if (Meteor.isServer) {
Meteor.startup(() => {
// Date of 7 days ago
let lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
Presences.remove({ ttl: { $lte: lastWeek } });
// Create index for serverId that is queried often
Presences._collection._ensureIndex({ serverId: -1 });
});
}
export default Presences;
+3 -21
View File
@@ -1,7 +1,8 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
const { SimpleSchema } = require('/imports/simpleSchema');
Rules = new Mongo.Collection('rules');
const Rules = new Mongo.Collection('rules');
Rules.attachSchema(
new SimpleSchema({
@@ -37,7 +38,6 @@ Rules.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -71,22 +71,4 @@ Rules.helpers({
},
});
Rules.allow({
async insert(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
async update(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
async remove(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await Rules._collection.createIndexAsync({ modifiedAt: -1 });
});
}
export default Rules;
+2
View File
@@ -1,3 +1,5 @@
import { Meteor } from 'meteor/meteor';
/**
* Executes a function only if we are on the server. Use in combination
* with package-sepcific loader functions to create a "nested" import that
+2 -1
View File
@@ -1,7 +1,8 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { createWorkbook } from './createWorkbook';
import { fileStoreStrategyFactory } from '/models/attachments';
import { fileStoreStrategyFactory } from '/models/attachments.server';
import fs from 'fs';
import path from 'path';
import { execSync } from 'node:child_process';
+10 -367
View File
@@ -2,10 +2,16 @@
// settings.js → ReactiveCache → i18n/tap → translation.js → ReactiveCache → settings.js
// All dependencies use require() so Settings collection is defined first.
Settings = new Mongo.Collection('settings');
const { FlowRouter } = require('meteor/ostrio:flow-router-extra');
const { ReactiveCache } = require('/imports/reactiveCache');
const { TAPi18n } = require('/imports/i18n');
const Meteor = Package.meteor.Meteor;
const Mongo = Package.mongo.Mongo;
const Settings = new Mongo.Collection('settings');
const FlowRouter = Package['ostrio:flow-router-extra'].FlowRouter;
// Lazy getter — avoids circular dependency (reactiveCache imports settings)
const getReactiveCache = () => require('/imports/reactiveCache').ReactiveCache;
// Lazy getter — avoids circular dependency (i18n/tap → reactiveCache → settings)
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
const { SimpleSchema } = require('/imports/simpleSchema');
const InvitationCodes = require('/models/invitationCodes').default;
//var nodemailer = require('nodemailer');
// Sandstorm context is detected using the METEOR_SETTINGS environment variable
@@ -190,7 +196,6 @@ Settings.attachSchema(
},
createdAt: {
type: Date,
denyUpdate: true,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert) {
@@ -229,367 +234,5 @@ Settings.helpers({
)}@${this.mailServer.host}:${this.mailServer.port}/`;
},
});
Settings.allow({
async update(userId) {
const user = await Meteor.users.findOneAsync(userId);
return user && user.isAdmin;
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await Settings._collection.createIndexAsync({ modifiedAt: -1 });
const setting = await ReactiveCache.getCurrentSetting();
if (!setting) {
const now = new Date();
const domain = process.env.ROOT_URL.match(
/\/\/(?:www\.)?(.*)?(?:\/)?/,
)[1];
const from = `Boards Support <support@${domain}>`;
const defaultSetting = {
disableRegistration: false,
mailServer: {
username: '',
password: '',
host: '',
port: '',
enableTLS: false,
from,
},
createdAt: now,
modifiedAt: now,
displayAuthenticationMethod: true,
defaultAuthenticationMethod: 'password',
};
Settings.insert(defaultSetting);
}
if (isSandstorm) {
// At Sandstorm, Admin Panel has SMTP settings
const newSetting = await ReactiveCache.getCurrentSetting();
if (!process.env.MAIL_URL && newSetting.mailUrl())
process.env.MAIL_URL = newSetting.mailUrl();
Accounts.emailTemplates.from = process.env.MAIL_FROM
? process.env.MAIL_FROM
: newSetting.mailServer.from;
} else {
// Not running on Sandstorm, so using environment variables
Accounts.emailTemplates.from = process.env.MAIL_FROM;
}
});
if (isSandstorm) {
// At Sandstorm Wekan Admin Panel, save SMTP settings.
Settings.after.update((userId, doc, fieldNames) => {
// assign new values to mail-from & MAIL_URL in environment
if (fieldNames.includes('mailServer') && doc.mailServer.host) {
const protocol = doc.mailServer.enableTLS ? 'smtps://' : 'smtp://';
if (!doc.mailServer.username && !doc.mailServer.password) {
process.env.MAIL_URL = `${protocol}${doc.mailServer.host}:${doc.mailServer.port}/`;
} else {
process.env.MAIL_URL = `${protocol}${
doc.mailServer.username
}:${encodeURIComponent(doc.mailServer.password)}@${
doc.mailServer.host
}:${doc.mailServer.port}/`;
}
Accounts.emailTemplates.from = doc.mailServer.from;
}
});
}
function getRandomNum(min, max) {
const range = max - min;
const rand = Math.random();
return min + Math.round(rand * range);
}
function getEnvVar(name) {
const value = process.env[name];
if (value) {
return value;
}
throw new Meteor.Error([
'var-not-exist',
`The environment variable ${name} does not exist`,
]);
}
function loadOidcConfig(service){
check(service, String);
var config = ServiceConfiguration.configurations.findOne({service: service});
return config;
}
async function sendInvitationEmail(_id) {
const icode = await ReactiveCache.getInvitationCode(_id);
const author = await ReactiveCache.getCurrentUser();
try {
const authorUser = await ReactiveCache.getUser(icode.authorId);
const fullName = authorUser?.profile?.fullname || "";
const params = {
email: icode.email,
inviter: fullName != "" ? fullName + " (" + authorUser.username + " )" : authorUser.username,
user: icode.email.split('@')[0],
icode: icode.code,
url: FlowRouter.url('sign-up'),
};
const lang = author.getLanguage();
// Use EmailLocalization utility to handle email in the proper language
if (typeof EmailLocalization !== 'undefined') {
await EmailLocalization.sendEmail({
to: icode.email,
from: Accounts.emailTemplates.from,
subject: 'email-invite-register-subject',
text: 'email-invite-register-text',
params: params,
language: lang
});
} else {
// Fallback if EmailLocalization is not available
await Email.sendAsync({
to: icode.email,
from: Accounts.emailTemplates.from,
subject: TAPi18n.__('email-invite-register-subject', params, lang),
text: TAPi18n.__('email-invite-register-text', params, lang),
});
}
} catch (e) {
InvitationCodes.remove(_id);
throw new Meteor.Error('email-fail', e.message);
}
}
async function isNonAdminAllowedToSendMail(currentUser){
const currSett = await ReactiveCache.getCurrentSetting();
let isAllowed = false;
if(currSett && currSett != undefined && currSett.disableRegistration && currSett.mailDomainName !== undefined && currSett.mailDomainName != ""){
for(let i = 0; i < currentUser.emails.length; i++) {
if(currentUser.emails[i].address.endsWith(currSett.mailDomainName)){
isAllowed = true;
break;
}
}
}
return isAllowed;
}
function isLdapEnabled() {
return (
process.env.LDAP_ENABLE === 'true' || process.env.LDAP_ENABLE === true
);
}
function isOauth2Enabled() {
return (
process.env.OAUTH2_ENABLED === 'true' ||
process.env.OAUTH2_ENABLED === true
);
}
function isCasEnabled() {
return (
process.env.CAS_ENABLED === 'true' || process.env.CAS_ENABLED === true
);
}
function isApiEnabled() {
return process.env.WITH_API === 'true' || process.env.WITH_API === true;
}
Meteor.methods({
async sendInvitation(emails, boards) {
let rc = 0;
check(emails, [String]);
check(boards, [String]);
const user = await ReactiveCache.getCurrentUser();
if (!user.isAdmin && !(await isNonAdminAllowedToSendMail(user))) {
rc = -1;
throw new Meteor.Error('not-allowed');
}
for (const email of emails) {
if (email && SimpleSchema.RegEx.Email.test(email)) {
// Checks if the email is already link to an account.
const userExist = await ReactiveCache.getUser({ email });
if (userExist) {
rc = -1;
throw new Meteor.Error(
'user-exist',
`The user with the email ${email} has already an account.`,
);
}
// Checks if the email is already link to an invitation.
const invitation = await ReactiveCache.getInvitationCode({ email });
if (invitation) {
await InvitationCodes.updateAsync(invitation, {
$set: { boardsToBeInvited: boards },
});
await sendInvitationEmail(invitation._id);
} else {
const code = getRandomNum(100000, 999999);
const _id = await InvitationCodes.insertAsync(
{
code,
email,
boardsToBeInvited: boards,
createdAt: new Date(),
authorId: Meteor.userId(),
},
);
if (_id) {
await sendInvitationEmail(_id);
} else {
rc = -1;
throw new Meteor.Error(
'invitation-generated-fail',
'Failed to create invitation code',
);
}
}
}
}
return rc;
},
async sendSMTPTestEmail() {
if (!Meteor.userId()) {
throw new Meteor.Error('invalid-user');
}
const user = await ReactiveCache.getCurrentUser();
if (!user.emails || !user.emails[0] || !user.emails[0].address) {
throw new Meteor.Error('email-invalid');
}
this.unblock();
const lang = user.getLanguage();
try {
/*
if (process.env.MAIL_SERVICE !== '') {
let transporter = nodemailer.createTransport({
service: process.env.MAIL_SERVICE,
auth: {
user: process.env.MAIL_SERVICE_USER,
pass: process.env.MAIL_SERVICE_PASSWORD
},
})
let info = transporter.sendMail({
to: user.emails[0].address,
from: Accounts.emailTemplates.from,
subject: TAPi18n.__('email-smtp-test-subject', { lng: lang }),
text: TAPi18n.__('email-smtp-test-text', { lng: lang }),
})
} else {
await Email.sendAsync({
to: user.emails[0].address,
from: Accounts.emailTemplates.from,
subject: TAPi18n.__('email-smtp-test-subject', { lng: lang }),
text: TAPi18n.__('email-smtp-test-text', { lng: lang }),
});
}
*/
await Email.sendAsync({
to: user.emails[0].address,
from: Accounts.emailTemplates.from,
subject: TAPi18n.__('email-smtp-test-subject', { lng: lang }),
text: TAPi18n.__('email-smtp-test-text', { lng: lang }),
});
} catch ({ message }) {
throw new Meteor.Error(
'email-fail',
`${TAPi18n.__('email-fail-text', { lng: lang })}: ${message}`,
message,
);
}
return {
message: 'email-sent',
email: user.emails[0].address,
};
},
async getCustomUI() {
const setting = await ReactiveCache.getCurrentSetting();
if (!setting.productName) {
return {
productName: '',
};
} else {
return {
productName: `${setting.productName}`,
};
}
},
async isDisableRegistration() {
const setting = await ReactiveCache.getCurrentSetting();
if (setting.disableRegistration === true) {
return true;
} else {
return false;
}
},
async isDisableForgotPassword() {
const setting = await ReactiveCache.getCurrentSetting();
if (setting.disableForgotPassword === true) {
return true;
} else {
return false;
}
},
getMatomoConf() {
return {
address: getEnvVar('MATOMO_ADDRESS'),
siteId: getEnvVar('MATOMO_SITE_ID'),
doNotTrack: process.env.MATOMO_DO_NOT_TRACK || false,
withUserName: process.env.MATOMO_WITH_USERNAME || false,
};
},
_isLdapEnabled() {
return isLdapEnabled();
},
_isOauth2Enabled() {
return isOauth2Enabled();
},
_isCasEnabled() {
return isCasEnabled();
},
_isApiEnabled() {
return isApiEnabled();
},
// Gets all connection methods to use it in the Template
getAuthenticationsEnabled() {
return {
ldap: isLdapEnabled(),
oauth2: isOauth2Enabled(),
cas: isCasEnabled(),
};
},
getOauthServerUrl(){
return process.env.OAUTH2_SERVER_URL;
},
getOauthDashboardUrl(){
return process.env.DASHBOARD_URL;
},
getDefaultAuthenticationMethod() {
return process.env.DEFAULT_AUTHENTICATION_METHOD;
},
isPasswordLoginEnabled() {
return !(process.env.PASSWORD_LOGIN_ENABLED === 'false');
},
isOidcRedirectionEnabled(){
return process.env.OIDC_REDIRECTION_ENABLED === 'true' && Object.keys(loadOidcConfig("oidc")).length > 0;
},
getServiceConfiguration(service){
return loadOidcConfig(service);
}
});
}
export default Settings;
+41 -308
View File
@@ -1,8 +1,15 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
import { ALLOWED_COLORS } from '/config/const';
import { SWIMLANE_COLORS } from '/models/metadata/colors';
import PositionHistory from './positionHistory';
import Activities from '/models/activities';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Lists from '/models/lists';
const { SimpleSchema } = require('/imports/simpleSchema');
Swimlanes = new Mongo.Collection('swimlanes');
const Swimlanes = new Mongo.Collection('swimlanes');
/**
* A swimlane is an line in the kaban board.
@@ -61,7 +68,6 @@ Swimlanes.attachSchema(
* the sort value of the swimlane
*/
type: Number,
decimal: true,
// XXX We should probably provide a default
optional: true,
},
@@ -72,7 +78,7 @@ Swimlanes.attachSchema(
type: String,
optional: true,
// silver is the default
allowedValues: ALLOWED_COLORS,
allowedValues: SWIMLANE_COLORS,
},
updatedAt: {
/**
@@ -91,7 +97,6 @@ Swimlanes.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -130,22 +135,6 @@ Swimlanes.attachSchema(
}),
);
Swimlanes.allow({
async insert(userId, doc) {
// ReadOnly and CommentOnly users cannot create swimlanes
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
},
async update(userId, doc) {
// ReadOnly and CommentOnly users cannot edit swimlanes
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
},
async remove(userId, doc) {
// ReadOnly and CommentOnly users cannot delete swimlanes
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
},
fetch: ['boardId'],
});
Swimlanes.helpers({
async copy(boardId, targetSwimlaneId = null, position = 'below', title = '') {
const oldId = this._id;
@@ -488,314 +477,58 @@ Swimlanes.archivedSwimlaneIds = async () => {
Swimlanes.hookOptions.after.update = { fetchPrevious: false };
if (Meteor.isServer) {
Meteor.startup(async () => {
await Swimlanes._collection.createIndexAsync({ modifiedAt: -1 });
await Swimlanes._collection.createIndexAsync({ boardId: 1 });
});
Swimlanes.after.insert((userId, doc) => {
Activities.insert({
userId,
type: 'swimlane',
activityType: 'createSwimlane',
boardId: doc.boardId,
swimlaneId: doc._id,
});
// Track original position for new swimlanes
Meteor.setTimeout(() => {
const swimlane = Swimlanes.findOne(doc._id);
if (swimlane) {
swimlane.trackOriginalPosition();
}
}, 100);
});
Swimlanes.before.remove(async function(userId, doc) {
const lists = await ReactiveCache.getLists(
{
boardId: doc.boardId,
swimlaneId: { $in: [doc._id, ''] },
archived: false,
},
{ sort: ['sort'] },
);
if (lists.length < 2) {
for (const list of lists) {
await list.remove();
}
} else {
await Cards.removeAsync({ swimlaneId: doc._id });
}
await Activities.insertAsync({
userId,
type: 'swimlane',
activityType: 'removeSwimlane',
boardId: doc.boardId,
swimlaneId: doc._id,
title: doc.title,
});
});
Swimlanes.after.update((userId, doc, fieldNames) => {
if (fieldNames.includes('title')) {
Activities.insert({
userId,
type: 'swimlane',
activityType: 'changedSwimlaneTitle',
listId: doc._id,
boardId: doc.boardId,
// this preserves the name so that the activity can be useful after the
// list is deleted
title: doc.title,
});
} else if (doc.archived) {
Activities.insert({
userId,
type: 'swimlane',
activityType: 'archivedSwimlane',
listId: doc._id,
boardId: doc.boardId,
// this preserves the name so that the activity can be useful after the
// list is deleted
title: doc.title,
});
} else if (fieldNames.includes('archived')) {
Activities.insert({
userId,
type: 'swimlane',
activityType: 'restoredSwimlane',
listId: doc._id,
boardId: doc.boardId,
// this preserves the name so that the activity can be useful after the
// list is deleted
title: doc.title,
});
}
});
}
//SWIMLANE REST API
if (Meteor.isServer) {
/**
* @operation get_all_swimlanes
*
* @summary Get the list of swimlanes attached to a board
*
* @param {string} boardId the ID of the board
* @return_type [{_id: string,
* title: string}]
*/
JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const swimlanes = await ReactiveCache.getSwimlanes({ boardId: paramBoardId, archived: false });
JsonRoutes.sendResult(res, {
code: 200,
data: swimlanes.map(
function(doc) {
return {
_id: doc._id,
title: doc.title,
};
},
),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation get_swimlane
*
* @summary Get a swimlane
*
* @param {string} boardId the ID of the board
* @param {string} swimlaneId the ID of the swimlane
* @return_type Swimlanes
*/
JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes/:swimlaneId', async function(
req,
res,
) {
try {
const paramBoardId = req.params.boardId;
const paramSwimlaneId = req.params.swimlaneId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
JsonRoutes.sendResult(res, {
code: 200,
data: await ReactiveCache.getSwimlane({
_id: paramSwimlaneId,
boardId: paramBoardId,
archived: false,
}),
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation new_swimlane
*
* @summary Add a swimlane to a board
*
* @param {string} boardId the ID of the board
* @param {string} title the new title of the swimlane
* @return_type {_id: string}
*/
JsonRoutes.add('POST', '/api/boards/:boardId/swimlanes', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
const board = await ReactiveCache.getBoard(paramBoardId);
const id = await Swimlanes.insertAsync({
title: req.body.title,
boardId: paramBoardId,
sort: board.swimlanes().length,
});
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: id,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation edit_swimlane
*
* @summary Edit the title of a swimlane
*
* @param {string} boardId the ID of the board
* @param {string} swimlaneId the ID of the swimlane to edit
* @param {string} title the new title of the swimlane
* @return_type {_id: string}
*/
JsonRoutes.add('PUT', '/api/boards/:boardId/swimlanes/:swimlaneId', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
const paramSwimlaneId = req.params.swimlaneId;
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
const board = await ReactiveCache.getBoard(paramBoardId);
const swimlane = await ReactiveCache.getSwimlane({
_id: paramSwimlaneId,
boardId: paramBoardId,
});
if (!swimlane) {
throw new Meteor.Error('not-found', 'Swimlane not found');
}
Swimlanes.direct.update(
{ _id: paramSwimlaneId },
{ $set: { title: req.body.title } }
);
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramSwimlaneId,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
/**
* @operation delete_swimlane
*
* @summary Delete a swimlane
*
* @description The swimlane will be deleted, not moved to the recycle bin
*
* @param {string} boardId the ID of the board
* @param {string} swimlaneId the ID of the swimlane
* @return_type {_id: string}
*/
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/swimlanes/:swimlaneId',
function(req, res) {
try {
const paramBoardId = req.params.boardId;
const paramSwimlaneId = req.params.swimlaneId;
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
Swimlanes.remove({ _id: paramSwimlaneId, boardId: paramBoardId });
JsonRoutes.sendResult(res, {
code: 200,
data: {
_id: paramSwimlaneId,
},
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
},
);
}
// Position history tracking methods
Swimlanes.helpers({
/**
* Track the original position of this swimlane
*/
trackOriginalPosition() {
const existingHistory = PositionHistory.findOne({
const selector = {
boardId: this.boardId,
entityType: 'swimlane',
entityId: this._id,
});
};
const document = {
boardId: this.boardId,
entityType: 'swimlane',
entityId: this._id,
originalPosition: {
sort: this.sort,
title: this.title,
},
originalTitle: this.title,
createdAt: new Date(),
updatedAt: new Date(),
};
if (!existingHistory) {
PositionHistory.insert({
boardId: this.boardId,
entityType: 'swimlane',
entityId: this._id,
originalPosition: {
sort: this.sort,
title: this.title,
},
originalTitle: this.title,
createdAt: new Date(),
updatedAt: new Date(),
if (Meteor.isServer) {
return PositionHistory.findOneAsync(selector).then(existingHistory => {
if (!existingHistory) {
return PositionHistory.insertAsync(document);
}
return existingHistory;
});
}
const existingHistory = PositionHistory.findOne(selector);
if (!existingHistory) {
PositionHistory.insert(document);
}
},
/**
* Get the original position history for this swimlane
*/
getOriginalPosition() {
return PositionHistory.findOne({
const selector = {
boardId: this.boardId,
entityType: 'swimlane',
entityId: this._id,
});
};
if (Meteor.isServer) {
return PositionHistory.findOneAsync(selector);
}
return PositionHistory.findOne(selector);
},
/**
+3 -25
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
TableVisibilityModeSettings = new Mongo.Collection('tableVisibilityModeSettings');
const TableVisibilityModeSettings = new Mongo.Collection('tableVisibilityModeSettings');
TableVisibilityModeSettings.attachSchema(
new SimpleSchema({
@@ -13,7 +15,6 @@ TableVisibilityModeSettings.attachSchema(
},
sort: {
type: Number,
decimal: true,
},
createdAt: {
type: Date,
@@ -31,7 +32,6 @@ TableVisibilityModeSettings.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -44,28 +44,6 @@ TableVisibilityModeSettings.attachSchema(
}),
);
TableVisibilityModeSettings.allow({
async update(userId) {
const user = await Meteor.users.findOneAsync(userId);
return user && user.isAdmin;
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await TableVisibilityModeSettings._collection.createIndexAsync({ modifiedAt: -1 });
TableVisibilityModeSettings.upsert(
{ _id: 'tableVisibilityMode-allowPrivateOnly' },
{
$setOnInsert: {
booleanValue: false,
sort: 0,
},
},
);
});
}
TableVisibilityModeSettings.helpers({
allowPrivateOnly() {
return TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
+3 -198
View File
@@ -1,6 +1,8 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
const { SimpleSchema } = require('/imports/simpleSchema');
Team = new Mongo.Collection('team');
const Team = new Mongo.Collection('team');
/**
* A Team in Wekan. Organization in Trello.
@@ -63,7 +65,6 @@ Team.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -76,200 +77,4 @@ Team.attachSchema(
}),
);
if (Meteor.isServer) {
Team.allow({
async insert(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
async update(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
async remove(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
fetch: [],
});
Meteor.methods({
async setCreateTeam(
teamDisplayName,
teamDesc,
teamShortName,
teamWebsite,
teamIsActive,
) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(teamDisplayName, String);
check(teamDesc, String);
check(teamShortName, String);
check(teamWebsite, String);
check(teamIsActive, Boolean);
const nTeamNames = (await ReactiveCache.getTeams({ teamShortName })).length;
if (nTeamNames > 0) {
throw new Meteor.Error('teamname-already-taken');
} else {
await Team.insertAsync({
teamDisplayName,
teamDesc,
teamShortName,
teamWebsite,
teamIsActive,
});
}
}
},
async setCreateTeamFromOidc(
teamDisplayName,
teamDesc,
teamShortName,
teamWebsite,
teamIsActive,
) {
check(teamDisplayName, String);
check(teamDesc, String);
check(teamShortName, String);
check(teamWebsite, String);
check(teamIsActive, Boolean);
const nTeamNames = (await ReactiveCache.getTeams({ teamShortName })).length;
if (nTeamNames > 0) {
throw new Meteor.Error('teamname-already-taken');
} else {
await Team.insertAsync({
teamDisplayName,
teamDesc,
teamShortName,
teamWebsite,
teamIsActive,
});
}
},
async setTeamDisplayName(team, teamDisplayName) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(team, Object);
check(teamDisplayName, String);
Team.update(team, {
$set: { teamDisplayName: teamDisplayName },
});
await Meteor.callAsync('setUsersTeamsTeamDisplayName', team._id, teamDisplayName);
}
},
async setTeamDesc(team, teamDesc) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(team, Object);
check(teamDesc, String);
Team.update(team, {
$set: { teamDesc: teamDesc },
});
}
},
async setTeamShortName(team, teamShortName) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(team, Object);
check(teamShortName, String);
Team.update(team, {
$set: { teamShortName: teamShortName },
});
}
},
async setTeamIsActive(team, teamIsActive) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(team, Object);
check(teamIsActive, Boolean);
Team.update(team, {
$set: { teamIsActive: teamIsActive },
});
}
},
async setTeamAllFieldsFromOidc(
team,
teamDisplayName,
teamDesc,
teamShortName,
teamWebsite,
teamIsActive,
) {
check(team, Object);
check(teamDisplayName, String);
check(teamDesc, String);
check(teamShortName, String);
check(teamWebsite, String);
check(teamIsActive, Boolean);
Team.update(team, {
$set: {
teamDisplayName: teamDisplayName,
teamDesc: teamDesc,
teamShortName: teamShortName,
teamWebsite: teamWebsite,
teamIsActive: teamIsActive,
},
});
await Meteor.callAsync('setUsersTeamsTeamDisplayName', team._id, teamDisplayName);
},
async setTeamAllFields(
team,
teamDisplayName,
teamDesc,
teamShortName,
teamWebsite,
teamIsActive,
) {
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
check(team, Object);
check(teamDisplayName, String);
check(teamDesc, String);
check(teamShortName, String);
check(teamWebsite, String);
check(teamIsActive, Boolean);
Team.update(team, {
$set: {
teamDisplayName: teamDisplayName,
teamDesc: teamDesc,
teamShortName: teamShortName,
teamWebsite: teamWebsite,
teamIsActive: teamIsActive,
},
});
await Meteor.callAsync('setUsersTeamsTeamDisplayName', team._id, teamDisplayName);
}
},
async getTeamsCollectionCount(query = {}) {
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
throw new Meteor.Error('not-authorized');
}
check(query, Match.OneOf(Object, null, undefined));
const count = await (await ReactiveCache.getTeams(query, {}, true)).count();
return count;
},
});
}
if (Meteor.isServer) {
// Index for Team name.
Meteor.startup(async () => {
await Team._collection.createIndexAsync({ teamDisplayName: 1 });
});
}
export default Team;
+4 -90
View File
@@ -1,4 +1,7 @@
Translation = new Mongo.Collection('translation');
import { Mongo } from 'meteor/mongo';
const { SimpleSchema } = require('/imports/simpleSchema');
const Translation = new Mongo.Collection('translation');
/**
* A Organization User in wekan
@@ -43,7 +46,6 @@ Translation.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -56,92 +58,4 @@ Translation.attachSchema(
}),
);
if (Meteor.isServer) {
Translation.allow({
async insert(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
async update(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
async remove(userId, doc) {
const user = await Meteor.users.findOneAsync(userId);
if (user?.isAdmin)
return true;
if (!user) {
return false;
}
return doc._id === userId;
},
fetch: [],
});
Meteor.methods({
async setCreateTranslation(
language,
text,
translationText,
) {
check(language, String);
check(text, String);
check(translationText, String);
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
throw new Meteor.Error('not-authorized');
}
const nTexts = (await ReactiveCache.getTranslations({ language, text })).length;
if (nTexts > 0) {
throw new Meteor.Error('text-already-taken');
} else {
await Translation.insertAsync({
language,
text,
translationText,
});
}
},
async setTranslationText(translation, translationText) {
check(translation, Object);
check(translationText, String);
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
throw new Meteor.Error('not-authorized');
}
Translation.update(translation, {
$set: { translationText: translationText },
});
},
async deleteTranslation(translationId) {
check(translationId, String);
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
throw new Meteor.Error('not-authorized');
}
Translation.remove(translationId);
},
});
}
if (Meteor.isServer) {
// Index for Organization User.
Meteor.startup(async () => {
await Translation._collection.createIndexAsync({ modifiedAt: -1 });
});
}
export default Translation;
+55 -44
View File
@@ -1,6 +1,17 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { CustomFields } from './customFields';
import Activities from '/models/activities';
import Attachments from '/models/attachments';
import Boards from '/models/boards';
import { BOARD_COLORS } from '/models/metadata/colors';
import CardComments from '/models/cardComments';
import Cards from '/models/cards';
import ChecklistItems from '/models/checklistItems';
import Checklists from '/models/checklists';
import CustomFields from './customFields';
import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
import {
formatDateTime,
formatDate,
@@ -252,10 +263,10 @@ export class TrelloCreator {
boardToCreate.labels.push(labelToCreate);
});
}
const boardId = Boards.direct.insert(boardToCreate);
Boards.direct.update(boardId, { $set: { modifiedAt: this._now() } });
const boardId = await Boards.direct.insertAsync(boardToCreate);
await Boards.direct.updateAsync(boardId, { $set: { modifiedAt: this._now() } });
// log activity
Activities.direct.insert({
await Activities.direct.insertAsync({
activityType: 'importBoard',
boardId,
createdAt: this._now(),
@@ -269,7 +280,7 @@ export class TrelloCreator {
userId: this._user(),
});
if (trelloBoard.customFields) {
trelloBoard.customFields.forEach(field => {
for (const field of trelloBoard.customFields) {
const fieldToCreate = {
// trelloId: field.id,
name: field.name,
@@ -296,8 +307,8 @@ export class TrelloCreator {
// We need to remember them by Trello ID, as this is the only ref we have
// when importing cards.
this.customFields[field.id] = CustomFields.direct.insert(fieldToCreate);
});
this.customFields[field.id] = await CustomFields.direct.insertAsync(fieldToCreate);
}
}
return boardId;
}
@@ -398,7 +409,7 @@ export class TrelloCreator {
}
// insert card
const cardId = Cards.direct.insert(cardToCreate);
const cardId = await Cards.direct.insertAsync(cardToCreate);
// keep track of Trello id => Wekan id
this.cards[card.id] = cardId;
// log activity
@@ -420,7 +431,7 @@ export class TrelloCreator {
// add comments
const comments = this.comments[card.id];
if (comments) {
comments.forEach(comment => {
for (const comment of comments) {
const commentToCreate = {
boardId,
cardId,
@@ -431,10 +442,10 @@ export class TrelloCreator {
};
// dateLastActivity will be set from activity insert, no need to
// update it ourselves
const commentId = CardComments.direct.insert(commentToCreate);
const commentId = await CardComments.direct.insertAsync(commentToCreate);
// We need to keep adding comment activities this way with Trello
// because it doesn't provide a comment ID
Activities.direct.insert({
await Activities.direct.insertAsync({
activityType: 'addComment',
boardId: commentToCreate.boardId,
cardId: commentToCreate.cardId,
@@ -444,7 +455,7 @@ export class TrelloCreator {
// to the original author - it is needed by some UI elements.
userId: commentToCreate.userId,
});
});
}
}
const attachments = this.attachments[card.id];
const trelloCoverId = card.idAttachmentCover;
@@ -460,13 +471,13 @@ export class TrelloCreator {
source: 'import',
},
};
const cb = (error, fileObj) => {
const cb = async (error, fileObj) => {
if (error) {
throw error;
}
self.attachmentIds[att._id] = fileObj._id;
if (trelloCoverId === att._id) {
Cards.direct.update(cardId, {
await Cards.direct.updateAsync(cardId, {
$set: { coverId: fileObj._id },
});
}
@@ -499,7 +510,7 @@ export class TrelloCreator {
links.forEach(link => {
desc += `* ${link}\n`;
});
Cards.direct.update(cardId, {
await Cards.direct.updateAsync(cardId, {
$set: {
description: desc,
},
@@ -527,8 +538,8 @@ export class TrelloCreator {
});
}
createLists(trelloLists, boardId) {
trelloLists.forEach(list => {
async createLists(trelloLists, boardId) {
for (const list of trelloLists) {
const listToCreate = {
archived: list.closed,
boardId,
@@ -540,8 +551,8 @@ export class TrelloCreator {
title: list.name,
sort: list.pos,
};
const listId = Lists.direct.insert(listToCreate);
Lists.direct.update(listId, { $set: { updatedAt: this._now() } });
const listId = await Lists.direct.insertAsync(listToCreate);
await Lists.direct.updateAsync(listId, { $set: { updatedAt: this._now() } });
this.lists[list.id] = listId;
// log activity
// Activities.direct.insert({
@@ -557,10 +568,10 @@ export class TrelloCreator {
// // not the creator of the original object
// userId: this._user(),
// });
});
}
}
createSwimlanes(boardId) {
async createSwimlanes(boardId) {
const swimlaneToCreate = {
archived: false,
boardId,
@@ -572,13 +583,13 @@ export class TrelloCreator {
title: 'Default',
sort: 1,
};
const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate);
Swimlanes.direct.update(swimlaneId, { $set: { updatedAt: this._now() } });
const swimlaneId = await Swimlanes.direct.insertAsync(swimlaneToCreate);
await Swimlanes.direct.updateAsync(swimlaneId, { $set: { updatedAt: this._now() } });
this.swimlane = swimlaneId;
}
createChecklists(trelloChecklists) {
trelloChecklists.forEach(checklist => {
async createChecklists(trelloChecklists) {
for (const checklist of trelloChecklists) {
if (this.cards[checklist.idCard]) {
// Create the checklist
const checklistToCreate = {
@@ -587,12 +598,12 @@ export class TrelloCreator {
createdAt: this._now(),
sort: checklist.pos,
};
const checklistId = Checklists.direct.insert(checklistToCreate);
const checklistId = await Checklists.direct.insertAsync(checklistToCreate);
// keep track of Trello id => Wekan id
this.checklists[checklist.id] = checklistId;
// Now add the items to the checklistItems
let counter = 0;
checklist.checkItems.forEach(item => {
for (const item of checklist.checkItems) {
counter++;
const checklistItemTocreate = {
_id: checklistId + counter,
@@ -602,10 +613,10 @@ export class TrelloCreator {
sort: item.pos,
isFinished: item.state === 'complete',
};
ChecklistItems.direct.insert(checklistItemTocreate);
});
await ChecklistItems.direct.insertAsync(checklistItemTocreate);
}
}
});
}
}
getAdmin(trelloMemberType) {
@@ -626,7 +637,7 @@ export class TrelloCreator {
grey: 'midnight',
};
const wekanColor = mapColors[trelloColorCode];
return wekanColor || Boards.simpleSchema()._schema.color.allowedValues[0];
return wekanColor || BOARD_COLORS[0];
}
getPermission(trelloPermissionCode) {
@@ -676,13 +687,13 @@ export class TrelloCreator {
});
}
importActions(actions, boardId) {
actions.forEach(action => {
async importActions(actions, boardId) {
for (const action of actions) {
switch (action.type) {
// Board related actions
// TODO: addBoardMember, removeBoardMember
case 'createBoard': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(action.idMemberCreator),
type: 'board',
activityTypeId: boardId,
@@ -695,7 +706,7 @@ export class TrelloCreator {
// List related activities
// TODO: removeList, archivedList
case 'createList': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(action.idMemberCreator),
type: 'list',
activityType: 'createList',
@@ -708,7 +719,7 @@ export class TrelloCreator {
// Card related activities
// TODO: archivedCard, restoredCard, joinMember, unjoinMember
case 'createCard': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(action.idMemberCreator),
activityType: 'createCard',
listId: this.lists[action.data.list.id],
@@ -720,7 +731,7 @@ export class TrelloCreator {
}
case 'updateCard': {
if (action.data.old.idList) {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(action.idMemberCreator),
oldListId: this.lists[action.data.old.idList],
activityType: 'moveCard',
@@ -736,7 +747,7 @@ export class TrelloCreator {
// Trello doesn't export the comment id
// Attachment related activities
case 'addAttachmentToCard': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(action.idMemberCreator),
type: 'card',
activityType: 'addAttachment',
@@ -749,7 +760,7 @@ export class TrelloCreator {
}
// Checklist related activities
case 'addChecklistToCard': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(action.idMemberCreator),
activityType: 'addChecklist',
cardId: this.cards[action.data.card.id],
@@ -761,7 +772,7 @@ export class TrelloCreator {
}
}
// Trello doesn't have an add checklist item action
});
}
}
check(board) {
@@ -792,11 +803,11 @@ export class TrelloCreator {
}
this.parseActions(board.actions);
const boardId = await this.createBoardAndLabels(board);
this.createLists(board.lists, boardId);
this.createSwimlanes(boardId);
await this.createLists(board.lists, boardId);
await this.createSwimlanes(boardId);
await this.createCards(board.cards, boardId);
this.createChecklists(board.checklists);
this.importActions(board.actions, boardId);
await this.createChecklists(board.checklists);
await this.importActions(board.actions, boardId);
// XXX add members
return boardId;
}
+2 -20
View File
@@ -1,7 +1,7 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
Triggers = new Mongo.Collection('triggers');
const Triggers = new Mongo.Collection('triggers');
Triggers.before.insert((userId, doc) => {
doc.createdAt = new Date();
@@ -13,18 +13,6 @@ Triggers.before.update((userId, doc, fieldNames, modifier) => {
modifier.$set.updatedAt = new Date();
});
Triggers.allow({
async insert(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
async update(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
async remove(userId, doc) {
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
},
});
Triggers.helpers({
async rename(description) {
return await Triggers.updateAsync(this._id, {
@@ -63,10 +51,4 @@ Triggers.helpers({
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await Triggers._collection.createIndexAsync({ modifiedAt: -1 });
});
}
export default Triggers;
+4 -18
View File
@@ -1,6 +1,9 @@
// This collection shouldn't be manipulated directly by instead throw the
// `UnsavedEdits` API on the client.
UnsavedEditCollection = new Mongo.Collection('unsaved-edits');
import { Mongo } from 'meteor/mongo';
const { SimpleSchema } = require('/imports/simpleSchema');
const UnsavedEditCollection = new Mongo.Collection('unsaved-edits');
UnsavedEditCollection.attachSchema(
new SimpleSchema({
@@ -38,7 +41,6 @@ UnsavedEditCollection.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
@@ -51,20 +53,4 @@ UnsavedEditCollection.attachSchema(
}),
);
if (Meteor.isServer) {
function isAuthor(userId, doc, fieldNames = []) {
return userId === doc.userId && fieldNames.indexOf('userId') === -1;
}
Meteor.startup(async () => {
await UnsavedEditCollection._collection.createIndexAsync({ modifiedAt: -1 });
await UnsavedEditCollection._collection.createIndexAsync({ userId: 1 });
});
UnsavedEditCollection.allow({
insert: isAuthor,
update: isAuthor,
remove: isAuthor,
fetch: ['userId'],
});
}
export default UnsavedEditCollection;
+18 -237
View File
@@ -1,10 +1,19 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { SimpleSchema } from '/imports/simpleSchema';
import { ReactiveCache } from '/imports/reactiveCache';
import Boards from '/models/boards';
import Cards from '/models/cards';
import ChecklistItems from '/models/checklistItems';
import Checklists from '/models/checklists';
import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
/**
* UserPositionHistory collection - Per-user history of entity movements
* Similar to Activities but specifically for tracking position changes with undo/redo support
*/
UserPositionHistory = new Mongo.Collection('userPositionHistory');
const UserPositionHistory = new Mongo.Collection('userPositionHistory');
UserPositionHistory.attachSchema(
new SimpleSchema({
@@ -58,12 +67,10 @@ UserPositionHistory.attachSchema(
// For easier undo operations, store specific fields
previousSort: {
type: Number,
decimal: true,
optional: true,
},
newSort: {
type: Number,
decimal: true,
optional: true,
},
previousSwimlaneId: {
@@ -132,22 +139,6 @@ UserPositionHistory.attachSchema(
}),
);
UserPositionHistory.allow({
insert(userId, doc) {
// Only allow users to create their own history
return userId && doc.userId === userId;
},
update(userId, doc) {
// Only allow users to update their own history (for checkpoints)
return userId && doc.userId === userId;
},
remove() {
// Don't allow removal - history is permanent
return false;
},
fetch: ['userId'],
});
UserPositionHistory.helpers({
/**
* Get a human-readable description of this change
@@ -186,7 +177,7 @@ UserPositionHistory.helpers({
case 'checklist':
return !!(await ReactiveCache.getChecklist(this.entityId));
case 'checklistItem':
return !!ChecklistItems.findOne(this.entityId);
return !!(await ChecklistItems.findOneAsync(this.entityId));
default:
return false;
}
@@ -212,7 +203,7 @@ UserPositionHistory.helpers({
const listId = this.previousListId || card.listId;
const sort = this.previousSort !== undefined ? this.previousSort : card.sort;
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: {
boardId,
swimlaneId,
@@ -229,7 +220,7 @@ UserPositionHistory.helpers({
const sort = this.previousSort !== undefined ? this.previousSort : list.sort;
const swimlaneId = this.previousSwimlaneId || list.swimlaneId;
Lists.update(list._id, {
await Lists.updateAsync(list._id, {
$set: {
sort,
swimlaneId,
@@ -243,7 +234,7 @@ UserPositionHistory.helpers({
if (swimlane) {
const sort = this.previousSort !== undefined ? this.previousSort : swimlane.sort;
Swimlanes.update(swimlane._id, {
await Swimlanes.updateAsync(swimlane._id, {
$set: {
sort,
},
@@ -256,7 +247,7 @@ UserPositionHistory.helpers({
if (checklist) {
const sort = this.previousSort !== undefined ? this.previousSort : checklist.sort;
Checklists.update(checklist._id, {
await Checklists.updateAsync(checklist._id, {
$set: {
sort,
},
@@ -266,12 +257,12 @@ UserPositionHistory.helpers({
}
case 'checklistItem': {
if (typeof ChecklistItems !== 'undefined') {
const item = ChecklistItems.findOne(this.entityId);
const item = await ChecklistItems.findOneAsync(this.entityId);
if (item) {
const sort = this.previousSort !== undefined ? this.previousSort : item.sort;
const checklistId = this.previousState?.checklistId || item.checklistId;
ChecklistItems.update(item._id, {
await ChecklistItems.updateAsync(item._id, {
$set: {
sort,
checklistId,
@@ -285,214 +276,4 @@ UserPositionHistory.helpers({
},
});
if (Meteor.isServer) {
Meteor.startup(async () => {
await UserPositionHistory._collection.createIndexAsync({ userId: 1, boardId: 1, createdAt: -1 });
await UserPositionHistory._collection.createIndexAsync({ userId: 1, entityType: 1, entityId: 1 });
await UserPositionHistory._collection.createIndexAsync({ userId: 1, isCheckpoint: 1 });
await UserPositionHistory._collection.createIndexAsync({ batchId: 1 });
await UserPositionHistory._collection.createIndexAsync({ createdAt: 1 }); // For cleanup of old entries
});
/**
* Helper to track a position change
*/
UserPositionHistory.trackChange = function(options) {
const {
userId,
boardId,
entityType,
entityId,
actionType,
previousState,
newState,
batchId,
} = options;
if (!userId || !boardId || !entityType || !entityId || !actionType) {
throw new Meteor.Error('invalid-params', 'Missing required parameters');
}
const historyEntry = {
userId,
boardId,
entityType,
entityId,
actionType,
newState,
};
if (previousState) {
historyEntry.previousState = previousState;
historyEntry.previousSort = previousState.sort;
historyEntry.previousSwimlaneId = previousState.swimlaneId;
historyEntry.previousListId = previousState.listId;
historyEntry.previousBoardId = previousState.boardId;
}
if (newState) {
historyEntry.newSort = newState.sort;
historyEntry.newSwimlaneId = newState.swimlaneId;
historyEntry.newListId = newState.listId;
historyEntry.newBoardId = newState.boardId;
}
if (batchId) {
historyEntry.batchId = batchId;
}
return UserPositionHistory.insert(historyEntry);
};
/**
* Cleanup old history entries (keep last 1000 per user per board)
*/
UserPositionHistory.cleanup = function() {
const users = Meteor.users.find({}, { fields: { _id: 1 } }).fetch();
users.forEach(user => {
const boards = Boards.find({ 'members.userId': user._id }, { fields: { _id: 1 } }).fetch();
boards.forEach(board => {
const history = UserPositionHistory.find(
{ userId: user._id, boardId: board._id, isCheckpoint: { $ne: true } },
{ sort: { createdAt: -1 }, limit: 1000 }
).fetch();
if (history.length >= 1000) {
const oldestToKeep = history[999].createdAt;
// Remove entries older than the 1000th entry (except checkpoints)
UserPositionHistory.remove({
userId: user._id,
boardId: board._id,
createdAt: { $lt: oldestToKeep },
isCheckpoint: { $ne: true },
});
}
});
});
};
// Run cleanup daily
if (Meteor.settings.public?.enableHistoryCleanup !== false) {
Meteor.setInterval(() => {
try {
UserPositionHistory.cleanup();
} catch (e) {
console.error('Error during history cleanup:', e);
}
}, 24 * 60 * 60 * 1000); // Once per day
}
}
// Meteor Methods for client interaction
Meteor.methods({
async 'userPositionHistory.createCheckpoint'(boardId, checkpointName) {
check(boardId, String);
check(checkpointName, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
// Create a checkpoint entry
return await UserPositionHistory.insertAsync({
userId: this.userId,
boardId,
entityType: 'checkpoint',
entityId: 'checkpoint',
actionType: 'create',
isCheckpoint: true,
checkpointName,
newState: {
timestamp: new Date(),
},
});
},
async 'userPositionHistory.undo'(historyId) {
check(historyId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const history = UserPositionHistory.findOne({ _id: historyId, userId: this.userId });
if (!history) {
throw new Meteor.Error('not-found', 'History entry not found');
}
return await history.undo();
},
async 'userPositionHistory.getRecent'(boardId, limit = 50) {
check(boardId, String);
check(limit, Number);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
return await UserPositionHistory.find(
{ userId: this.userId, boardId },
{ sort: { createdAt: -1 }, limit: Math.min(limit, 100) }
).fetchAsync();
},
async 'userPositionHistory.getCheckpoints'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
return await UserPositionHistory.find(
{ userId: this.userId, boardId, isCheckpoint: true },
{ sort: { createdAt: -1 } }
).fetchAsync();
},
async 'userPositionHistory.restoreToCheckpoint'(checkpointId) {
check(checkpointId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const checkpoint = UserPositionHistory.findOne({
_id: checkpointId,
userId: this.userId,
isCheckpoint: true,
});
if (!checkpoint) {
throw new Meteor.Error('not-found', 'Checkpoint not found');
}
// Find all changes after this checkpoint and undo them in reverse order
const changesToUndo = UserPositionHistory.find(
{
userId: this.userId,
boardId: checkpoint.boardId,
createdAt: { $gt: checkpoint.createdAt },
isCheckpoint: { $ne: true },
},
{ sort: { createdAt: -1 } }
).fetch();
let undoneCount = 0;
for (const change of changesToUndo) {
try {
if (await change.canUndo()) {
await change.undo();
undoneCount++;
}
} catch (e) {
console.warn('Failed to undo change:', change._id, e);
}
};
return { undoneCount, totalChanges: changesToUndo.length };
},
});
export default UserPositionHistory;
+50 -2030
View File
File diff suppressed because it is too large. Load diff
+13 -5
View File
@@ -1,6 +1,9 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { incrementCounter } from './counters';
const { SimpleSchema } = require('/imports/simpleSchema');
SessionData = new Mongo.Collection('sessiondata');
const SessionData = new Mongo.Collection('sessiondata');
/**
* A UserSessionData in Wekan. Organization in Trello.
@@ -56,9 +59,12 @@ SessionData.attachSchema(
optional: true,
},
cards: {
type: [String],
type: Array,
optional: true,
},
'cards.$': {
type: String,
},
selector: {
type: String,
optional: true,
@@ -71,11 +77,14 @@ SessionData.attachSchema(
defaultValue: {},
},
errorMessages: {
type: [String],
type: Array,
optional: true,
},
'errorMessages.$': {
type: String,
},
errors: {
type: [Object],
type: Array,
optional: true,
defaultValue: [],
},
@@ -125,7 +134,6 @@ SessionData.attachSchema(
},
modifiedAt: {
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
+12 -2
View File
@@ -1,10 +1,17 @@
import Boards from '/models/boards';
import Lists from '/models/lists';
import Cards from '/models/cards';
// simple version, only toggle watch / unwatch
const simpleWatchable = collection => {
collection.attachSchema({
watchers: {
type: [String],
type: Array,
optional: true,
},
'watchers.$': {
type: String,
},
});
collection.helpers({
@@ -37,9 +44,12 @@ const complexWatchDefault = 'muted';
const complexWatchable = collection => {
collection.attachSchema({
watchers: {
type: [Object],
type: Array,
optional: true,
},
'watchers.$': {
type: Object,
},
'watchers.$.userId': {
type: String,
},
+81 -68
View File
@@ -1,5 +1,18 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { CustomFields } from './customFields';
import Actions from '/models/actions';
import Activities from '/models/activities';
import Attachments from '/models/attachments';
import Boards from '/models/boards';
import CardComments from '/models/cardComments';
import Cards from '/models/cards';
import ChecklistItems from '/models/checklistItems';
import Checklists from '/models/checklists';
import CustomFields from './customFields';
import Lists from '/models/lists';
import Rules from '/models/rules';
import Swimlanes from '/models/swimlanes';
import Triggers from '/models/triggers';
import {
formatDateTime,
formatDate,
@@ -338,14 +351,14 @@ export class WekanCreator {
});
}
const boardId = Boards.direct.insert(boardToCreate);
Boards.direct.update(boardId, {
const boardId = await Boards.direct.insertAsync(boardToCreate);
await Boards.direct.updateAsync(boardId, {
$set: {
modifiedAt: this._now(),
},
});
// log activity
Activities.direct.insert({
await Activities.direct.insertAsync({
activityType: 'importBoard',
boardId,
createdAt: this._now(),
@@ -448,7 +461,7 @@ export class WekanCreator {
}
// insert card
const cardId = Cards.direct.insert(cardToCreate);
const cardId = await Cards.direct.insertAsync(cardToCreate);
// keep track of Wekan id => Wekan id
this.cards[card._id] = cardId;
// // log activity
@@ -469,7 +482,7 @@ export class WekanCreator {
// add comments
const comments = this.comments[card._id];
if (comments) {
comments.forEach(comment => {
for (const comment of comments) {
const commentToCreate = {
boardId,
cardId,
@@ -480,7 +493,7 @@ export class WekanCreator {
};
// dateLastActivity will be set from activity insert, no need to
// update it ourselves
const commentId = CardComments.direct.insert(commentToCreate);
const commentId = await CardComments.direct.insertAsync(commentToCreate);
this.commentIds[comment._id] = commentId;
// Activities.direct.insert({
// activityType: 'addComment',
@@ -492,7 +505,7 @@ export class WekanCreator {
// // to the original author - it is needed by some UI elements.
// userId: commentToCreate.userId,
// });
});
}
}
const attachments = this.attachments[card._id];
const wekanCoverId = card.coverId;
@@ -508,13 +521,13 @@ export class WekanCreator {
source: 'import',
},
};
const cb = (error, fileObj) => {
const cb = async (error, fileObj) => {
if (error) {
throw error;
}
self.attachmentIds[att._id] = fileObj._id;
if (wekanCoverId === att._id) {
Cards.direct.update(cardId, {
await Cards.direct.updateAsync(cardId, {
$set: { coverId: fileObj._id },
});
}
@@ -548,8 +561,8 @@ export class WekanCreator {
* @param wekanCustomFields
* @param boardId
*/
createCustomFields(wekanCustomFields, boardId) {
wekanCustomFields.forEach((field, fieldIndex) => {
async createCustomFields(wekanCustomFields, boardId) {
for (const field of wekanCustomFields) {
const fieldToCreate = {
boardIds: [boardId],
name: field.name,
@@ -564,16 +577,16 @@ export class WekanCreator {
modifiedAt: field.modifiedAt,
};
//insert copy of custom field
const fieldId = CustomFields.direct.insert(fieldToCreate);
const fieldId = await CustomFields.direct.insertAsync(fieldToCreate);
//set modified date to now
CustomFields.direct.update(fieldId, {
await CustomFields.direct.updateAsync(fieldId, {
$set: {
modifiedAt: this._now(),
},
});
//store mapping of old id to new id
this.customFields[field._id] = fieldId;
});
}
}
// Create labels if they do not exist and load this.labels.
@@ -591,8 +604,8 @@ export class WekanCreator {
});
}
createLists(wekanLists, boardId) {
wekanLists.forEach((list, listIndex) => {
async createLists(wekanLists, boardId) {
for (const [listIndex, list] of wekanLists.entries()) {
const listToCreate = {
archived: list.archived,
boardId,
@@ -604,8 +617,8 @@ export class WekanCreator {
title: list.title,
sort: list.sort ? list.sort : listIndex,
};
const listId = Lists.direct.insert(listToCreate);
Lists.direct.update(listId, {
const listId = await Lists.direct.insertAsync(listToCreate);
await Lists.direct.updateAsync(listId, {
$set: {
updatedAt: this._now(),
},
@@ -625,10 +638,10 @@ export class WekanCreator {
// // not the creator of the original object
// userId: this._user(),
// });
});
}
}
createSwimlanes(wekanSwimlanes, boardId) {
async createSwimlanes(wekanSwimlanes, boardId) {
// If no swimlanes provided, create a default so cards still render
if (!wekanSwimlanes || wekanSwimlanes.length === 0) {
const swimlaneToCreate = {
@@ -638,8 +651,8 @@ export class WekanCreator {
title: 'Default',
sort: 0,
};
const created = Swimlanes.direct.insert(swimlaneToCreate);
Swimlanes.direct.update(created, {
const created = await Swimlanes.direct.insertAsync(swimlaneToCreate);
await Swimlanes.direct.updateAsync(created, {
$set: {
updatedAt: this._now(),
},
@@ -648,7 +661,7 @@ export class WekanCreator {
return;
}
wekanSwimlanes.forEach((swimlane, swimlaneIndex) => {
for (const [swimlaneIndex, swimlane] of wekanSwimlanes.entries()) {
const swimlaneToCreate = {
archived: swimlane.archived,
boardId,
@@ -664,8 +677,8 @@ export class WekanCreator {
if (swimlane.color) {
swimlaneToCreate.color = swimlane.color;
}
const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate);
Swimlanes.direct.update(swimlaneId, {
const swimlaneId = await Swimlanes.direct.insertAsync(swimlaneToCreate);
await Swimlanes.direct.updateAsync(swimlaneId, {
$set: {
updatedAt: this._now(),
},
@@ -674,7 +687,7 @@ export class WekanCreator {
if (!this._defaultSwimlaneId) {
this._defaultSwimlaneId = swimlaneId;
}
});
}
}
async createSubtasks(wekanCards) {
@@ -697,7 +710,7 @@ export class WekanCreator {
//if the parent card exists, proceed
if (await ReactiveCache.getCard(parentIdInNewBoard)) {
//set parent id of the card in the new board to the new id of the parent
Cards.direct.update(cardIdInNewBoard, {
await Cards.direct.updateAsync(cardIdInNewBoard, {
$set: {
parentId: parentIdInNewBoard,
},
@@ -706,9 +719,9 @@ export class WekanCreator {
}
}
createChecklists(wekanChecklists) {
async createChecklists(wekanChecklists) {
const result = [];
wekanChecklists.forEach((checklist, checklistIndex) => {
for (const [checklistIndex, checklist] of wekanChecklists.entries()) {
// Create the checklist
const checklistToCreate = {
cardId: this.cards[checklist.cardId],
@@ -716,15 +729,15 @@ export class WekanCreator {
createdAt: checklist.createdAt,
sort: checklist.sort ? checklist.sort : checklistIndex,
};
const checklistId = Checklists.direct.insert(checklistToCreate);
const checklistId = await Checklists.direct.insertAsync(checklistToCreate);
this.checklists[checklist._id] = checklistId;
result.push(checklistId);
});
}
return result;
}
createTriggers(wekanTriggers, boardId) {
wekanTriggers.forEach(trigger => {
async createTriggers(wekanTriggers, boardId) {
for (const trigger of wekanTriggers) {
if (trigger.hasOwnProperty('labelId')) {
trigger.labelId = this.labels[trigger.labelId];
}
@@ -734,12 +747,12 @@ export class WekanCreator {
trigger.boardId = boardId;
const oldId = trigger._id;
delete trigger._id;
this.triggers[oldId] = Triggers.direct.insert(trigger);
});
this.triggers[oldId] = await Triggers.direct.insertAsync(trigger);
}
}
createActions(wekanActions, boardId) {
wekanActions.forEach(action => {
async createActions(wekanActions, boardId) {
for (const action of wekanActions) {
if (action.hasOwnProperty('labelId')) {
action.labelId = this.labels[action.labelId];
}
@@ -749,23 +762,23 @@ export class WekanCreator {
action.boardId = boardId;
const oldId = action._id;
delete action._id;
this.actions[oldId] = Actions.direct.insert(action);
});
this.actions[oldId] = await Actions.direct.insertAsync(action);
}
}
createRules(wekanRules, boardId) {
wekanRules.forEach(rule => {
async createRules(wekanRules, boardId) {
for (const rule of wekanRules) {
// Create the rule
rule.boardId = boardId;
rule.triggerId = this.triggers[rule.triggerId];
rule.actionId = this.actions[rule.actionId];
delete rule._id;
Rules.direct.insert(rule);
});
await Rules.direct.insertAsync(rule);
}
}
createChecklistItems(wekanChecklistItems) {
wekanChecklistItems.forEach((checklistitem, checklistitemIndex) => {
async createChecklistItems(wekanChecklistItems) {
for (const [checklistitemIndex, checklistitem] of wekanChecklistItems.entries()) {
//Check if the checklist for this item (still) exists
//If a checklist was deleted, but items remain, the import would error out here
//Leading to no further checklist items being imported
@@ -779,12 +792,12 @@ export class WekanCreator {
isFinished: checklistitem.isFinished,
};
const checklistItemId = ChecklistItems.direct.insert(
const checklistItemId = await ChecklistItems.direct.insertAsync(
checklistItemTocreate,
);
this.checklistItems[checklistitem._id] = checklistItemId;
}
});
}
}
parseActivities(wekanBoard) {
@@ -851,13 +864,13 @@ export class WekanCreator {
});
}
importActivities(activities, boardId) {
activities.forEach(activity => {
async importActivities(activities, boardId) {
for (const activity of activities) {
switch (activity.activityType) {
// Board related activities
// TODO: addBoardMember, removeBoardMember
case 'createBoard': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
type: 'board',
activityTypeId: boardId,
@@ -870,7 +883,7 @@ export class WekanCreator {
// List related activities
// TODO: removeList, archivedList
case 'createList': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
type: 'list',
activityType: activity.activityType,
@@ -883,7 +896,7 @@ export class WekanCreator {
// Card related activities
// TODO: archivedCard, restoredCard, joinMember, unjoinMember
case 'createCard': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
listId: this.lists[activity.listId],
@@ -894,7 +907,7 @@ export class WekanCreator {
break;
}
case 'moveCard': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
oldListId: this.lists[activity.oldListId],
activityType: activity.activityType,
@@ -907,7 +920,7 @@ export class WekanCreator {
}
// Comment related activities
case 'addComment': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
cardId: this.cards[activity.cardId],
@@ -919,7 +932,7 @@ export class WekanCreator {
}
// Attachment related activities
case 'addAttachment': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
type: 'card',
activityType: activity.activityType,
@@ -932,7 +945,7 @@ export class WekanCreator {
}
// Checklist related activities
case 'addChecklist': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
cardId: this.cards[activity.cardId],
@@ -943,7 +956,7 @@ export class WekanCreator {
break;
}
case 'addChecklistItem': {
Activities.direct.insert({
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
cardId: this.cards[activity.cardId],
@@ -958,7 +971,7 @@ export class WekanCreator {
break;
}
}
});
}
}
//check(board) {
@@ -995,17 +1008,17 @@ export class WekanCreator {
}
this.parseActivities(board);
const boardId = await this.createBoardAndLabels(board);
this.createLists(board.lists, boardId);
this.createSwimlanes(board.swimlanes, boardId);
this.createCustomFields(board.customFields, boardId);
await this.createLists(board.lists, boardId);
await this.createSwimlanes(board.swimlanes, boardId);
await this.createCustomFields(board.customFields, boardId);
await this.createCards(board.cards, boardId);
await this.createSubtasks(board.cards);
this.createChecklists(board.checklists);
this.createChecklistItems(board.checklistItems);
this.importActivities(board.activities, boardId);
this.createTriggers(board.triggers, boardId);
this.createActions(board.actions, boardId);
this.createRules(board.rules, boardId);
await this.createChecklists(board.checklists);
await this.createChecklistItems(board.checklistItems);
await this.importActivities(board.activities, boardId);
await this.createTriggers(board.triggers, boardId);
await this.createActions(board.actions, boardId);
await this.createRules(board.rules, boardId);
// XXX add members
return boardId;
}
+6 -15
View File
@@ -1,10 +1,12 @@
import DOMPurify from 'dompurify';
import MarkdownIt from 'markdown-it';
import * as markdownItEmoji from 'markdown-it-emoji';
import { getSecureDOMPurifyConfig } from './secureDOMPurify';
import { Blaze } from 'meteor/blaze';
import { HTML } from 'meteor/htmljs';
import { Template } from 'meteor/templating';
var Markdown = require('markdown-it')({
const Markdown = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
@@ -39,20 +41,9 @@ for(var i=0; i<urlschemes.length;i++){
Markdown.linkify.add(urlschemes[i]+":",'http:');
}
// Try to load emoji support, but don't fail if it's not available
try {
var emoji = require('markdown-it-emoji');
Markdown.use(emoji);
} catch (e) {
console.warn('markdown-it-emoji not available, emoji rendering disabled:', e.message);
}
// Try to load mathjax3, but don't fail if it's not available
try {
var mathjax = require('markdown-it-mathjax3');
Markdown.use(mathjax);
} catch (e) {
console.warn('markdown-it-mathjax3 not available, math rendering disabled:', e.message);
const emojiPlugin = markdownItEmoji.full || markdownItEmoji.default || markdownItEmoji;
if (emojiPlugin) {
Markdown.use(emojiPlugin);
}
// Custom plugin to prevent SVG-based DoS attacks
+2 -2
View File
@@ -119,9 +119,9 @@ let _userData = {};
//RoutePolicy.declare('/_cas/', 'network');
// Listen to incoming OAuth http requests
WebApp.handlers.use(Meteor.bindEnvironment((req, res, next) => {
WebApp.handlers.use((req, res, next) => {
middleware(req, res, next);
}));
});
const middleware = (req, res, next) => {
// Make sure to catch any exceptions because otherwise we'd crash
+2 -2
View File
@@ -139,7 +139,7 @@ if (__meteor_runtime_config__.SANDSTORM) {
});
}
var handlePostToken = Meteor.bindEnvironment(async function (req, res) {
var handlePostToken = async function (req, res) {
try {
// Note that cross-origin POSTs cannot set arbitrary Content-Types without explicit CORS
// permission, so this effectively prevents XSRF.
@@ -198,5 +198,5 @@ if (__meteor_runtime_config__.SANDSTORM) {
});
res.end(err.stack);
}
});
};
}
+2 -2
View File
@@ -440,7 +440,7 @@ function debounce(fn, wait) {
};
}
const addCronJob = debounce(Meteor.bindEnvironment(function addCronJobDebounced() {
const addCronJob = debounce(function addCronJobDebounced() {
let sc = SyncedCron;
if (LDAP.settings_get('LDAP_BACKGROUND_SYNC') !== true) {
log_info('Disabling LDAP Background Sync');
@@ -466,7 +466,7 @@ const addCronJob = debounce(Meteor.bindEnvironment(function addCronJobDebounced(
});
sc.start();
}), 500);
}, 500);
Meteor.startup(() => {
Meteor.defer(() => {
-12
View File
@@ -2,18 +2,6 @@ import '../models/users';
const fs = require('fs');
const os = require('os');
// Configure SyncedCron to suppress console logging
// This must be done before any SyncedCron operations
if (Meteor.isServer) {
const { SyncedCron } = require('meteor/quave:synced-cron');
SyncedCron.config({
log: false, // Disable console logging
collectionName: 'cronJobs', // Use custom collection name
utc: false, // Use local time
collectionTTL: 172800 // 2 days TTL
});
}
let errors = [];
if (!process.env.WRITABLE_PATH) {
errors.push("WRITABLE_PATH environment variable missing and/or unset, please configure !");
+6 -7
View File
@@ -1,9 +1,8 @@
Meteor.startup(() => {
if (Meteor.isServer) {
import { AccountsCommon } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
Accounts.config({
loginExpirationInDays: process.env.ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS || 90,
});
}
Meteor.startup(() => {
Accounts.config({
loginExpirationInDays: process.env.ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS || 90,
});
});
+155
View File
@@ -0,0 +1,155 @@
// Login and register REST endpoints.
// Replaces communitypackages:rest-accounts-password.
// Logic copied from the package source to maintain identical API behavior.
const { Meteor } = require('meteor/meteor');
const { Accounts } = require('meteor/accounts-base');
const { WebApp } = require('meteor/webapp');
const { check, Match } = require('meteor/check');
const { sendJsonResult } = require('/server/apiMiddleware');
const NonEmptyString = Match.Where(function (x) {
check(x, String);
return x.length > 0;
});
// ---------------------------------------------------------------------------
// POST /users/login
// ---------------------------------------------------------------------------
WebApp.handlers.options('/users/login', function (req, res) {
sendJsonResult(res);
});
WebApp.handlers.post('/users/login', async function (req, res) {
try {
const options = req.body;
let user;
if (options.email) {
check(options, {
email: String,
password: String,
code: Match.Optional(NonEmptyString),
});
user = await Meteor.users.findOneAsync({ 'emails.address': options.email });
} else {
check(options, {
username: String,
password: String,
code: Match.Optional(NonEmptyString),
});
user = await Meteor.users.findOneAsync({ username: options.username });
}
if (!user) {
throw new Meteor.Error(
'not-found',
'User with that username or email address not found.',
);
}
const result = await Accounts._checkPasswordAsync(user, options.password);
check(result, {
userId: String,
error: Match.Optional(Meteor.Error),
});
if (result.error) {
throw result.error;
}
// 2FA support
if (Accounts._check2faEnabled && Accounts._check2faEnabled(user)) {
if (!options.code) {
Accounts._handleError('2FA code must be informed', true, 'no-2fa-code');
}
if (
!Accounts._isTokenValid(
user.services.twoFactorAuthentication.secret,
options.code,
)
) {
Accounts._handleError('Invalid 2FA code', true, 'invalid-2fa-code');
}
}
const stampedLoginToken = Accounts._generateStampedLoginToken();
check(stampedLoginToken, { token: String, when: Date });
await Accounts._insertLoginToken(result.userId, stampedLoginToken);
const tokenExpiration = Accounts._tokenExpiration(stampedLoginToken.when);
check(tokenExpiration, Date);
sendJsonResult(res, {
data: {
id: result.userId,
token: stampedLoginToken.token,
tokenExpires: tokenExpiration,
},
});
} catch (error) {
res.statusCode = error.statusCode || 401;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: error.error || error.message || 'Login failed',
reason: error.reason || error.message,
}),
);
}
});
// ---------------------------------------------------------------------------
// POST /users/register
// ---------------------------------------------------------------------------
WebApp.handlers.options('/users/register', function (req, res) {
sendJsonResult(res);
});
WebApp.handlers.post('/users/register', async function (req, res) {
try {
if (Accounts._options.forbidClientAccountCreation) {
sendJsonResult(res, { code: 403 });
return;
}
const options = req.body;
check(options, {
username: Match.Optional(String),
email: Match.Optional(String),
password: String,
});
const userOptions = { password: options.password };
if (options.username) userOptions.username = options.username;
if (options.email) userOptions.email = options.email;
const userId = await Accounts.createUserAsync(userOptions);
const stampedLoginToken = Accounts._generateStampedLoginToken();
check(stampedLoginToken, { token: String, when: Date });
await Accounts._insertLoginToken(userId, stampedLoginToken);
const tokenExpiration = Accounts._tokenExpiration(stampedLoginToken.when);
check(tokenExpiration, Date);
sendJsonResult(res, {
data: {
token: stampedLoginToken.token,
tokenExpires: tokenExpiration,
id: userId,
},
});
} catch (error) {
res.statusCode = error.statusCode || 400;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: error.error || error.message || 'Registration failed',
reason: error.reason || error.message,
}),
);
}
});
+101
View File
@@ -0,0 +1,101 @@
// API middleware: body parsing, auth token handling, API gate, and JSON response helper.
// Replaces communitypackages:json-routes middleware chain.
// Must be imported before model files that register API routes.
const { Meteor } = require('meteor/meteor');
const { Accounts } = require('meteor/accounts-base');
const { WebApp } = require('meteor/webapp');
const bodyParser = require('body-parser');
// ---------------------------------------------------------------------------
// 1. Body parsing (previously registered by json-routes)
// ---------------------------------------------------------------------------
WebApp.handlers.use(bodyParser.urlencoded({ limit: '50mb', extended: false }));
WebApp.handlers.use(bodyParser.json({ limit: '50mb' }));
// ---------------------------------------------------------------------------
// 2. API gate — check WITH_API env var (previously in models/users.js)
// ---------------------------------------------------------------------------
WebApp.handlers.use(function apiGate(req, res, next) {
const api = req.url.startsWith('/api');
if ((api && process.env.WITH_API === 'true') || !api) {
return next();
}
res.writeHead(301, { Location: '/' });
return res.end();
});
// ---------------------------------------------------------------------------
// 3. Bearer token parser (replaces communitypackages:rest-bearer-token-parser)
// ---------------------------------------------------------------------------
WebApp.handlers.use(function parseBearerToken(req, res, next) {
// Check Authorization header first
const authHeader = req.headers.authorization;
if (authHeader) {
const match = authHeader.match(/^Bearer\s+(.+)$/i);
if (match) {
req.authToken = match[1];
}
}
// Fallback to access_token query param
if (!req.authToken && req.query && req.query.access_token) {
req.authToken = req.query.access_token;
}
next();
});
// ---------------------------------------------------------------------------
// 4. User authentication (replaces communitypackages:authenticate-user-by-token)
// ---------------------------------------------------------------------------
WebApp.handlers.use(async function authenticateByToken(req, res, next) {
if (req.authToken) {
try {
const hashedToken = Accounts._hashLoginToken(req.authToken);
const user = await Meteor.users.findOneAsync(
{ 'services.resume.loginTokens.hashedToken': hashedToken },
{ fields: { _id: 1 } },
);
if (user) {
req.userId = user._id;
}
} catch (e) {
// Ignore auth errors — routes handle missing userId themselves
}
}
next();
});
// ---------------------------------------------------------------------------
// 5. sendJsonResult — drop-in replacement for JsonRoutes.sendResult
// ---------------------------------------------------------------------------
function sendJsonResult(res, options) {
options = options || {};
// Default response headers (matching json-routes behavior)
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
// Custom headers
if (options.headers) {
Object.entries(options.headers).forEach(function ([key, value]) {
res.setHeader(key, value);
});
}
// Status code
res.statusCode = options.code || 200;
// JSON body
if (options.data !== undefined) {
const shouldPrettyPrint = process.env.NODE_ENV === 'development';
const spacer = shouldPrettyPrint ? 2 : null;
res.setHeader('Content-Type', 'application/json');
res.write(JSON.stringify(options.data, null, spacer));
}
res.end();
}
module.exports = { sendJsonResult };
+12 -13
View File
@@ -1,16 +1,16 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { Attachments, fileStoreStrategyFactory } from '/models/attachments';
import Attachments from '/models/attachments';
import { fileStoreStrategyFactory } from '/models/attachments.server';
import { moveToStorage } from '/models/lib/fileStoreStrategy';
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
import fs from 'fs';
import path from 'path';
import { ObjectID } from 'bson';
import { ObjectId } from 'bson';
// Attachment API methods
if (Meteor.isServer) {
Meteor.methods({
Meteor.methods({
// Upload attachment via API
async 'api.attachment.upload'(boardId, swimlaneId, listId, cardId, fileData, fileName, fileType, storageBackend) {
if (!this.userId) {
@@ -47,7 +47,7 @@ if (Meteor.isServer) {
let targetStorage = storageBackend;
if (!targetStorage) {
try {
const settings = AttachmentStorageSettings.findOne({});
const settings = await AttachmentStorageSettings.findOneAsync({});
targetStorage = settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
} catch (error) {
targetStorage = STORAGE_NAME_FILESYSTEM;
@@ -65,7 +65,7 @@ if (Meteor.isServer) {
const file = new File([fileBuffer], fileName, { type: fileType || 'application/octet-stream' });
// Create attachment metadata
const fileId = new ObjectID().toString();
const fileId = new ObjectId().toString();
const meta = {
boardId: boardId,
swimlaneId: swimlaneId,
@@ -77,7 +77,7 @@ if (Meteor.isServer) {
};
// Create attachment
const uploader = Attachments.insert({
const uploader = await Attachments.insertAsync({
file: file,
meta: meta,
isBase64: false,
@@ -274,13 +274,13 @@ if (Meteor.isServer) {
chunks.push(chunk);
});
readStream.on('end', () => {
readStream.on('end', async () => {
try {
const fileBuffer = Buffer.concat(chunks);
const file = new File([fileBuffer], sourceAttachment.name, { type: sourceAttachment.type });
// Create new attachment metadata
const fileId = new ObjectID().toString();
const fileId = new ObjectId().toString();
const meta = {
boardId: targetBoardId,
swimlaneId: targetSwimlaneId,
@@ -293,7 +293,7 @@ if (Meteor.isServer) {
};
// Create new attachment
const uploader = Attachments.insert({
const uploader = await Attachments.insertAsync({
file: file,
meta: meta,
isBase64: false,
@@ -358,7 +358,7 @@ if (Meteor.isServer) {
try {
// Update attachment metadata
Attachments.update(attachmentId, {
await Attachments.updateAsync(attachmentId, {
$set: {
'meta.boardId': targetBoardId,
'meta.swimlaneId': targetSwimlaneId,
@@ -404,7 +404,7 @@ if (Meteor.isServer) {
try {
// Delete attachment
Attachments.remove(attachmentId);
await Attachments.removeAsync(attachmentId);
return {
success: true,
@@ -465,4 +465,3 @@ if (Meteor.isServer) {
}
}
});
}
+13 -13
View File
@@ -8,7 +8,7 @@ import { ReactiveVar } from 'meteor/reactive-var';
import { check } from 'meteor/check';
import { ReactiveCache } from '/imports/reactiveCache';
import Attachments from '/models/attachments';
import { AttachmentMigrationStatus } from './attachmentMigrationStatus';
import AttachmentMigrationStatus from '/models/attachmentMigrationStatus';
// Reactive variables for tracking migration progress
const migrationProgress = new ReactiveVar(0);
@@ -61,9 +61,9 @@ class AttachmentMigrationService {
console.log(`Starting attachment migration for board: ${boardId}`);
// Get all attachments for the board
const attachments = Attachments.find({
const attachments = await Attachments.find({
'meta.boardId': boardId
}).fetch();
}).fetchAsync();
const totalAttachments = attachments.length;
let migratedCount = 0;
@@ -90,7 +90,7 @@ class AttachmentMigrationService {
}
// Update unconverted attachments list
const remainingUnconverted = this.getUnconvertedAttachments(boardId);
const remainingUnconverted = await this.getUnconvertedAttachments(boardId);
unconvertedAttachments.set(remainingUnconverted);
migrationStatus.set('Attachment migration completed');
@@ -183,7 +183,7 @@ class AttachmentMigrationService {
};
}
Attachments.update(attachment._id, { $set: updateData });
await Attachments.updateAsync(attachment._id, { $set: updateData });
console.log(`Migrated attachment ${attachment._id}`);
@@ -198,11 +198,11 @@ class AttachmentMigrationService {
* @param {string} boardId - The board ID
* @returns {Array} - Array of unconverted attachments
*/
getUnconvertedAttachments(boardId) {
async getUnconvertedAttachments(boardId) {
try {
const attachments = Attachments.find({
const attachments = await Attachments.find({
'meta.boardId': boardId
}).fetch();
}).fetchAsync();
return attachments.filter(attachment => this.needsMigration(attachment));
} catch (error) {
@@ -216,11 +216,11 @@ class AttachmentMigrationService {
* @param {string} boardId - The board ID
* @returns {Object} - Migration progress data
*/
getMigrationProgress(boardId) {
async getMigrationProgress(boardId) {
const progress = migrationProgress.get();
const status = migrationStatus.get();
const unconverted = this.getUnconvertedAttachments(boardId);
const total = Attachments.find({ 'meta.boardId': boardId }).count();
const unconverted = await this.getUnconvertedAttachments(boardId);
const total = await Attachments.find({ 'meta.boardId': boardId }).countAsync();
const migratedCount = total - unconverted.length;
// Update status collection for pub/sub
@@ -287,7 +287,7 @@ Meteor.methods({
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return attachmentMigrationService.getMigrationProgress(boardId);
return await attachmentMigrationService.getMigrationProgress(boardId);
},
async 'attachmentMigration.getUnconvertedAttachments'(boardId) {
@@ -321,4 +321,4 @@ Meteor.methods({
}
});
export { attachmentMigrationService };
export { attachmentMigrationService };
+7 -11
View File
@@ -1,18 +1,14 @@
import { Mongo } from 'meteor/mongo';
// Server-side collection for attachment migration status
export const AttachmentMigrationStatus = new Mongo.Collection('attachmentMigrationStatus');
import { Meteor } from 'meteor/meteor';
import AttachmentMigrationStatus from '/models/attachmentMigrationStatus';
// Allow/Deny rules
// This collection is server-only and should not be modified by clients
// Allow server-side operations (when userId is undefined) but deny all client operations
if (Meteor.isServer) {
AttachmentMigrationStatus.allow({
insert: (userId) => !userId,
update: (userId) => !userId,
remove: (userId) => !userId,
});
}
AttachmentMigrationStatus.allow({
insert: (userId) => !userId,
update: (userId) => !userId,
remove: (userId) => !userId,
});
// Create indexes for better query performance
Meteor.startup(() => {
+21 -27
View File
@@ -1,21 +1,8 @@
import { ReactiveCache } from '/imports/reactiveCache';
import Fiber from 'fibers';
Meteor.startup(() => {
// Node Fibers 100% CPU usage issue
// https://github.com/wekan/wekan-mongodb/issues/2#issuecomment-381453161
// https://github.com/meteor/meteor/issues/9796#issuecomment-381676326
// https://github.com/sandstorm-io/sandstorm/blob/0f1fec013fe7208ed0fd97eb88b31b77e3c61f42/shell/server/00-startup.js#L99-L129
Fiber.poolSize = 1e9;
Accounts.validateLoginAttempt(function(options) {
const user = options.user || {};
return !user.loginDisabled;
});
Authentication = {};
Authentication.checkUserId = async function(userId) {
// Authentication helpers — exported for use by API routes and model files
export const Authentication = {
async checkUserId(userId) {
if (userId === undefined) {
const error = new Meteor.Error('Unauthorized', 'Unauthorized');
error.statusCode = 401;
@@ -28,21 +15,21 @@ Meteor.startup(() => {
error.statusCode = 403;
throw error;
}
};
},
// This will only check if the user is logged in.
// The authorization checks for the user will have to be done inside each API endpoint
Authentication.checkLoggedIn = function(userId) {
checkLoggedIn(userId) {
if (userId === undefined) {
const error = new Meteor.Error('Unauthorized', 'Unauthorized');
error.statusCode = 401;
throw error;
}
};
},
// An admin should be authorized to access everything, so we use a separate check for admins
// This throws an error if otherReq is false and the user is not an admin
Authentication.checkAdminOrCondition = async function(userId, otherReq) {
async checkAdminOrCondition(userId, otherReq) {
if (otherReq) return;
const admin = await ReactiveCache.getUser({ _id: userId, isAdmin: true });
if (admin === undefined) {
@@ -50,31 +37,38 @@ Meteor.startup(() => {
error.statusCode = 403;
throw error;
}
};
},
// Helper function. Will throw an error if the user is not active BoardAdmin or active Normal user of the board.
Authentication.checkBoardAccess = async function(userId, boardId) {
async checkBoardAccess(userId, boardId) {
Authentication.checkLoggedIn(userId);
const board = await ReactiveCache.getBoard(boardId);
const normalAccess = board.members.some(e => e.userId === userId && e.isActive && !e.isNoComments && !e.isCommentOnly && !e.isWorker);
await Authentication.checkAdminOrCondition(userId, normalAccess);
};
},
// Helper function. Will throw an error if the user does not have write access to the board (excludes read-only users).
Authentication.checkBoardWriteAccess = async function(userId, boardId) {
async checkBoardWriteAccess(userId, boardId) {
Authentication.checkLoggedIn(userId);
const board = await ReactiveCache.getBoard(boardId);
const writeAccess = board.members.some(e => e.userId === userId && e.isActive && !e.isNoComments && !e.isCommentOnly && !e.isWorker && !e.isReadOnly && !e.isReadAssignedOnly);
await Authentication.checkAdminOrCondition(userId, writeAccess);
};
},
// Helper function. Will throw an error if the user is not a board admin.
Authentication.checkBoardAdmin = async function(userId, boardId) {
async checkBoardAdmin(userId, boardId) {
Authentication.checkLoggedIn(userId);
const board = await ReactiveCache.getBoard(boardId);
const adminAccess = board.members.some(e => e.userId === userId && e.isActive && e.isAdmin);
await Authentication.checkAdminOrCondition(userId, adminAccess);
};
},
};
Meteor.startup(() => {
Accounts.validateLoginAttempt(function(options) {
const user = options.user || {};
return !user.loginDisabled;
});
if (Meteor.isServer) {
if (
+27 -27
View File
@@ -60,9 +60,9 @@ class BoardMigrationDetector {
/**
* Check if system is idle and can run migrations
*/
isSystemIdle() {
async isSystemIdle() {
const resources = cronJobStorage.getSystemResources();
const queueStats = cronJobStorage.getQueueStats();
const queueStats = await cronJobStorage.getQueueStats();
// Check if no jobs are running
if (queueStats.running > 0) {
@@ -86,7 +86,7 @@ class BoardMigrationDetector {
* Check for idle migration opportunities
*/
async checkForIdleMigration() {
if (!this.isSystemIdle()) {
if (!(await this.isSystemIdle())) {
return;
}
@@ -97,7 +97,7 @@ class BoardMigrationDetector {
}
// Check if we can start a new job
const canStart = cronJobStorage.canStartNewJob();
const canStart = await cronJobStorage.canStartNewJob();
if (!canStart.canStart) {
return;
}
@@ -122,7 +122,7 @@ class BoardMigrationDetector {
// Scanning for unmigrated boards
// Get all boards from the database
const boards = this.getAllBoards();
const boards = await this.getAllBoards();
const unmigrated = [];
for (const board of boards) {
@@ -147,13 +147,13 @@ class BoardMigrationDetector {
/**
* Get all boards from the database
*/
getAllBoards() {
async getAllBoards() {
// This would need to be implemented based on your board model
// For now, we'll simulate getting boards
try {
// Assuming you have a Boards collection
if (typeof Boards !== 'undefined') {
return Boards.find({}, { fields: { _id: 1, title: 1, createdAt: 1, modifiedAt: 1 } }).fetch();
return await Boards.find({}, { fields: { _id: 1, title: 1, createdAt: 1, modifiedAt: 1 } }).fetchAsync();
}
// Fallback: return empty array if Boards collection not available
@@ -170,7 +170,7 @@ class BoardMigrationDetector {
async needsMigration(board) {
try {
// Check if board has been migrated by looking for migration markers
const migrationMarkers = this.getMigrationMarkers(board._id);
const migrationMarkers = await this.getMigrationMarkers(board._id);
// Check for specific migration indicators
const needsListMigration = !migrationMarkers.listsMigrated;
@@ -188,10 +188,10 @@ class BoardMigrationDetector {
/**
* Get migration markers for a board
*/
getMigrationMarkers(boardId) {
async getMigrationMarkers(boardId) {
try {
// Check if board has migration metadata
const board = Boards.findOne(boardId, { fields: { migrationMarkers: 1 } });
const board = await Boards.findOneAsync(boardId, { fields: { migrationMarkers: 1 } });
if (!board || !board.migrationMarkers) {
return {
@@ -217,7 +217,7 @@ class BoardMigrationDetector {
*/
async startBoardMigration(boardId) {
try {
const board = Boards.findOne(boardId);
const board = await Boards.findOneAsync(boardId);
if (!board) {
throw new Error(`Board ${boardId} not found`);
}
@@ -232,14 +232,14 @@ class BoardMigrationDetector {
const jobId = `board_migration_${board._id}_${Date.now()}`;
// Add to job queue with high priority
cronJobStorage.addToQueue(jobId, 'board_migration', 1, {
await cronJobStorage.addToQueue(jobId, 'board_migration', 1, {
boardId: board._id,
boardTitle: board.title,
migrationType: 'full_board_migration'
});
// Save initial job status
cronJobStorage.saveJobStatus(jobId, {
await cronJobStorage.saveJobStatus(jobId, {
jobType: 'board_migration',
status: 'pending',
progress: 0,
@@ -289,7 +289,7 @@ class BoardMigrationDetector {
/**
* Get detailed migration status for a specific board
*/
getBoardMigrationStatus(boardId) {
async getBoardMigrationStatus(boardId) {
const unmigrated = unmigratedBoards.get();
const isUnmigrated = unmigrated.some(b => b._id === boardId);
@@ -297,7 +297,7 @@ class BoardMigrationDetector {
return { needsMigration: false, reason: 'Board is already migrated' };
}
const migrationMarkers = this.getMigrationMarkers(boardId);
const migrationMarkers = await this.getMigrationMarkers(boardId);
const needsMigration = !migrationMarkers.listsMigrated ||
!migrationMarkers.attachmentsMigrated ||
!migrationMarkers.swimlanesMigrated;
@@ -312,7 +312,7 @@ class BoardMigrationDetector {
/**
* Mark a board as migrated
*/
markBoardAsMigrated(boardId, migrationType) {
async markBoardAsMigrated(boardId, migrationType) {
try {
// Update migration markers and version
const updateQuery = {};
@@ -320,7 +320,7 @@ class BoardMigrationDetector {
updateQuery['migrationMarkers.lastMigration'] = new Date();
updateQuery['migrationVersion'] = 1; // Set migration version to prevent re-migration
Boards.update(boardId, { $set: updateQuery });
await Boards.updateAsync(boardId, { $set: updateQuery });
// Remove from unmigrated list if present
const currentUnmigrated = unmigratedBoards.get();
@@ -364,17 +364,17 @@ Meteor.methods({
return boardMigrationDetector.forceScan();
},
'boardMigration.getBoardStatus'(boardId) {
async 'boardMigration.getBoardStatus'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return boardMigrationDetector.getBoardMigrationStatus(boardId);
return await boardMigrationDetector.getBoardMigrationStatus(boardId);
},
'boardMigration.markAsMigrated'(boardId, migrationType) {
async 'boardMigration.markAsMigrated'(boardId, migrationType) {
check(boardId, String);
check(migrationType, String);
@@ -382,7 +382,7 @@ Meteor.methods({
throw new Meteor.Error('not-authorized');
}
return boardMigrationDetector.markBoardAsMigrated(boardId, migrationType);
return await boardMigrationDetector.markBoardAsMigrated(boardId, migrationType);
},
'boardMigration.startBoardMigration'(boardId) {
@@ -395,24 +395,24 @@ Meteor.methods({
return boardMigrationDetector.startBoardMigration(boardId);
},
'boardMigration.fixStuckBoards'() {
async 'boardMigration.fixStuckBoards'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
// Find boards that have migration markers but no migrationVersion
const stuckBoards = Boards.find({
const stuckBoards = await Boards.find({
'migrationMarkers.fullMigrationCompleted': true,
$or: [
{ migrationVersion: { $exists: false } },
{ migrationVersion: { $lt: 1 } }
]
}).fetch();
}).fetchAsync();
let fixedCount = 0;
stuckBoards.forEach(board => {
for (const board of stuckBoards) {
try {
Boards.update(board._id, {
await Boards.updateAsync(board._id, {
$set: {
migrationVersion: 1,
'migrationMarkers.lastMigration': new Date()
@@ -423,7 +423,7 @@ Meteor.methods({
} catch (error) {
console.error(`Error fixing board ${board._id}:`, error);
}
});
}
return {
message: `Fixed ${fixedCount} stuck boards`,
+43
View File
@@ -0,0 +1,43 @@
let syncedCronConfigured = false;
let syncedCronInstance = null;
function getSyncedCronInstance() {
if (!syncedCronInstance) {
syncedCronInstance = require('meteor/quave:synced-cron').SyncedCron;
}
return syncedCronInstance;
}
export function configureSyncedCron() {
if (syncedCronConfigured) {
return;
}
const SyncedCron = getSyncedCronInstance();
SyncedCron.config({
log: false,
collectionName: 'cronJobs',
utc: false,
collectionTTL: 172800,
});
syncedCronConfigured = true;
}
export function startSyncedCron() {
configureSyncedCron();
getSyncedCronInstance().start();
}
export const SyncedCron = new Proxy(
{},
{
get(_target, prop) {
return getSyncedCronInstance()[prop];
},
set(_target, prop, value) {
getSyncedCronInstance()[prop] = value;
return true;
},
},
);
+73 -72
View File
@@ -5,9 +5,10 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import CronJobStatus from '/models/cronJobStatus';
// Collections for persistent storage
export const CronJobStatus = new Mongo.Collection('cronJobStatus');
export { CronJobStatus };
export const CronJobSteps = new Mongo.Collection('cronJobSteps');
export const CronJobQueue = new Mongo.Collection('cronJobQueue');
export const CronJobErrors = new Mongo.Collection('cronJobErrors');
@@ -97,12 +98,12 @@ class CronJobStorage {
/**
* Save job status to persistent storage
*/
saveJobStatus(jobId, jobData) {
async saveJobStatus(jobId, jobData) {
const now = new Date();
const existingJob = CronJobStatus.findOne({ jobId });
const existingJob = await CronJobStatus.findOneAsync({ jobId });
if (existingJob) {
CronJobStatus.update(
await CronJobStatus.updateAsync(
{ jobId },
{
$set: {
@@ -112,7 +113,7 @@ class CronJobStorage {
}
);
} else {
CronJobStatus.insert({
await CronJobStatus.insertAsync({
jobId,
...jobData,
createdAt: now,
@@ -124,28 +125,28 @@ class CronJobStorage {
/**
* Get job status from persistent storage
*/
getJobStatus(jobId) {
return CronJobStatus.findOne({ jobId });
async getJobStatus(jobId) {
return await CronJobStatus.findOneAsync({ jobId });
}
/**
* Get all incomplete jobs
*/
getIncompleteJobs() {
return CronJobStatus.find({
async getIncompleteJobs() {
return await CronJobStatus.find({
status: { $in: ['pending', 'running', 'paused'] }
}).fetch();
}).fetchAsync();
}
/**
* Save job step status
*/
saveJobStep(jobId, stepIndex, stepData) {
async saveJobStep(jobId, stepIndex, stepData) {
const now = new Date();
const existingStep = CronJobSteps.findOne({ jobId, stepIndex });
const existingStep = await CronJobSteps.findOneAsync({ jobId, stepIndex });
if (existingStep) {
CronJobSteps.update(
await CronJobSteps.updateAsync(
{ jobId, stepIndex },
{
$set: {
@@ -155,7 +156,7 @@ class CronJobStorage {
}
);
} else {
CronJobSteps.insert({
await CronJobSteps.insertAsync({
jobId,
stepIndex,
...stepData,
@@ -168,31 +169,31 @@ class CronJobStorage {
/**
* Get job steps
*/
getJobSteps(jobId) {
return CronJobSteps.find(
async getJobSteps(jobId) {
return await CronJobSteps.find(
{ jobId },
{ sort: { stepIndex: 1 } }
).fetch();
).fetchAsync();
}
/**
* Get incomplete steps for a job
*/
getIncompleteSteps(jobId) {
return CronJobSteps.find({
async getIncompleteSteps(jobId) {
return await CronJobSteps.find({
jobId,
status: { $in: ['pending', 'running'] }
}, { sort: { stepIndex: 1 } }).fetch();
}, { sort: { stepIndex: 1 } }).fetchAsync();
}
/**
* Save job error to persistent storage
*/
saveJobError(jobId, errorData) {
async saveJobError(jobId, errorData) {
const now = new Date();
const { stepId, stepIndex, error, severity = 'error', context = {} } = errorData;
CronJobErrors.insert({
await CronJobErrors.insertAsync({
jobId,
stepId,
stepIndex,
@@ -207,7 +208,7 @@ class CronJobStorage {
/**
* Get job errors from persistent storage
*/
getJobErrors(jobId, options = {}) {
async getJobErrors(jobId, options = {}) {
const { limit = 100, severity = null } = options;
const query = { jobId };
@@ -215,42 +216,42 @@ class CronJobStorage {
query.severity = severity;
}
return CronJobErrors.find(query, {
return await CronJobErrors.find(query, {
sort: { createdAt: -1 },
limit
}).fetch();
}).fetchAsync();
}
/**
* Get all recent errors across all jobs
*/
getAllRecentErrors(limit = 50) {
return CronJobErrors.find({}, {
async getAllRecentErrors(limit = 50) {
return await CronJobErrors.find({}, {
sort: { createdAt: -1 },
limit
}).fetch();
}).fetchAsync();
}
/**
* Clear errors for a specific job
*/
clearJobErrors(jobId) {
return CronJobErrors.remove({ jobId });
async clearJobErrors(jobId) {
return await CronJobErrors.removeAsync({ jobId });
}
/**
* Add job to queue
*/
addToQueue(jobId, jobType, priority = 5, jobData = {}) {
async addToQueue(jobId, jobType, priority = 5, jobData = {}) {
const now = new Date();
// Check if job already exists in queue
const existingJob = CronJobQueue.findOne({ jobId });
const existingJob = await CronJobQueue.findOneAsync({ jobId });
if (existingJob) {
return existingJob._id;
}
return CronJobQueue.insert({
return await CronJobQueue.insertAsync({
jobId,
jobType,
priority,
@@ -264,8 +265,8 @@ class CronJobStorage {
/**
* Get next job from queue
*/
getNextJob() {
return CronJobQueue.findOne({
async getNextJob() {
return await CronJobQueue.findOneAsync({
status: 'pending'
}, {
sort: { priority: 1, createdAt: 1 }
@@ -275,9 +276,9 @@ class CronJobStorage {
/**
* Update job queue status
*/
updateQueueStatus(jobId, status, additionalData = {}) {
async updateQueueStatus(jobId, status, additionalData = {}) {
const now = new Date();
CronJobQueue.update(
await CronJobQueue.updateAsync(
{ jobId },
{
$set: {
@@ -292,8 +293,8 @@ class CronJobStorage {
/**
* Remove job from queue
*/
removeFromQueue(jobId) {
CronJobQueue.remove({ jobId });
async removeFromQueue(jobId) {
await CronJobQueue.removeAsync({ jobId });
}
/**
@@ -333,9 +334,9 @@ class CronJobStorage {
/**
* Check if system can handle more jobs
*/
canStartNewJob() {
async canStartNewJob() {
const resources = this.getSystemResources();
const runningJobs = CronJobQueue.find({ status: 'running' }).count();
const runningJobs = await CronJobQueue.find({ status: 'running' }).countAsync();
// Check CPU and memory thresholds
if (resources.cpuUsage > this.cpuThreshold) {
@@ -357,12 +358,12 @@ class CronJobStorage {
/**
* Get queue statistics
*/
getQueueStats() {
const total = CronJobQueue.find().count();
const pending = CronJobQueue.find({ status: 'pending' }).count();
const running = CronJobQueue.find({ status: 'running' }).count();
const completed = CronJobQueue.find({ status: 'completed' }).count();
const failed = CronJobQueue.find({ status: 'failed' }).count();
async getQueueStats() {
const total = await CronJobQueue.find().countAsync();
const pending = await CronJobQueue.find({ status: 'pending' }).countAsync();
const running = await CronJobQueue.find({ status: 'running' }).countAsync();
const completed = await CronJobQueue.find({ status: 'completed' }).countAsync();
const failed = await CronJobQueue.find({ status: 'failed' }).countAsync();
return {
total,
@@ -377,24 +378,24 @@ class CronJobStorage {
/**
* Clean up old completed jobs
*/
cleanupOldJobs(daysOld = 7) {
async cleanupOldJobs(daysOld = 7) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
// Remove old completed jobs from queue
const removedQueue = CronJobQueue.remove({
const removedQueue = await CronJobQueue.removeAsync({
status: 'completed',
updatedAt: { $lt: cutoffDate }
});
// Remove old job statuses
const removedStatus = CronJobStatus.remove({
const removedStatus = await CronJobStatus.removeAsync({
status: 'completed',
updatedAt: { $lt: cutoffDate }
});
// Remove old job steps
const removedSteps = CronJobSteps.remove({
const removedSteps = await CronJobSteps.removeAsync({
status: 'completed',
updatedAt: { $lt: cutoffDate }
});
@@ -409,14 +410,14 @@ class CronJobStorage {
/**
* Resume incomplete jobs on startup
*/
resumeIncompleteJobs() {
const incompleteJobs = this.getIncompleteJobs();
async resumeIncompleteJobs() {
const incompleteJobs = await this.getIncompleteJobs();
const resumedJobs = [];
incompleteJobs.forEach(job => {
for (const job of incompleteJobs) {
// Reset running jobs to pending
if (job.status === 'running') {
this.saveJobStatus(job.jobId, {
await this.saveJobStatus(job.jobId, {
...job,
status: 'pending',
error: 'Job was interrupted during startup'
@@ -425,11 +426,11 @@ class CronJobStorage {
}
// Add to queue if not already there
const queueJob = CronJobQueue.findOne({ jobId: job.jobId });
const queueJob = await CronJobQueue.findOneAsync({ jobId: job.jobId });
if (!queueJob) {
this.addToQueue(job.jobId, job.jobType || 'unknown', job.priority || 5, job);
await this.addToQueue(job.jobId, job.jobType || 'unknown', job.priority || 5, job);
}
});
}
return resumedJobs;
}
@@ -437,8 +438,8 @@ class CronJobStorage {
/**
* Get job progress percentage
*/
getJobProgress(jobId) {
const steps = this.getJobSteps(jobId);
async getJobProgress(jobId) {
const steps = await this.getJobSteps(jobId);
if (steps.length === 0) return 0;
const completedSteps = steps.filter(step => step.status === 'completed').length;
@@ -448,10 +449,10 @@ class CronJobStorage {
/**
* Get detailed job information
*/
getJobDetails(jobId) {
const jobStatus = this.getJobStatus(jobId);
const jobSteps = this.getJobSteps(jobId);
const progress = this.getJobProgress(jobId);
async getJobDetails(jobId) {
const jobStatus = await this.getJobStatus(jobId);
const jobSteps = await this.getJobSteps(jobId);
const progress = await this.getJobProgress(jobId);
return {
...jobStatus,
@@ -465,13 +466,13 @@ class CronJobStorage {
/**
* Clear all jobs from storage
*/
clearAllJobs() {
async clearAllJobs() {
try {
// Clear all collections
CronJobStatus.remove({});
CronJobSteps.remove({});
CronJobQueue.remove({});
CronJobErrors.remove({});
await CronJobStatus.removeAsync({});
await CronJobSteps.removeAsync({});
await CronJobQueue.removeAsync({});
await CronJobErrors.removeAsync({});
console.log('All cron job data cleared from storage');
return { success: true, message: 'All cron job data cleared' };
@@ -486,15 +487,15 @@ class CronJobStorage {
export const cronJobStorage = new CronJobStorage();
// Cleanup old jobs on startup
Meteor.startup(() => {
Meteor.startup(async () => {
// Resume incomplete jobs
const resumedJobs = cronJobStorage.resumeIncompleteJobs();
const resumedJobs = await cronJobStorage.resumeIncompleteJobs();
if (resumedJobs.length > 0) {
// Resumed incomplete cron jobs
}
// Cleanup old jobs
const cleanup = cronJobStorage.cleanupOldJobs();
const cleanup = await cronJobStorage.cleanupOldJobs();
if (cleanup.removedQueue > 0 || cleanup.removedStatus > 0 || cleanup.removedSteps > 0) {
// Cleaned up old cron jobs
}
File diff suppressed because it is too large. Load diff
+71 -48
View File
@@ -3,6 +3,12 @@
// Loaded by server/main.js after bootstrap (collectionHelpers) completes.
// ============================================================================
// ----------------------------------------------------------------------------
// 0. API middleware & auth routes (must register before model routes)
// ----------------------------------------------------------------------------
import '/server/apiMiddleware';
import '/server/apiAuthRoutes';
// ----------------------------------------------------------------------------
// 1. Shared imports (i18n, utilities, reactive cache)
// ----------------------------------------------------------------------------
@@ -15,50 +21,19 @@ import '/imports/i18n/tap';
import '/imports/lib/customHeadDefaults';
import '/imports/lib/dateUtils';
import '/imports/lib/secureDOMPurify';
import '/imports/reactiveCache';
// ----------------------------------------------------------------------------
// 2. Models — shared collections (loaded on both client and server)
// 2. Models — shared collections + server-only extensions
// ----------------------------------------------------------------------------
import '/models/accessibilitySettings';
import '/models/accountSettings';
import '/models/actions';
import '/models/activities';
import '/models/announcements';
import '/models/attachments';
import '/models/attachmentStorageSettings';
import '/models/avatars';
import '/models/boards';
import '/models/cardCommentReactions';
import '/models/cardComments';
import '/models/cards';
import '/models/checklistItems';
import '/models/checklists';
import '/models/counters';
import '/models/customFields';
import '/imports/startup/shared-models';
import '/models/attachments.server';
import '/models/avatars.server';
import '/models/fileValidation';
import '/models/impersonatedUsers';
import '/models/integrations';
import '/models/invitationCodes';
import '/models/lists';
import '/models/lockoutSettings';
import '/models/org';
import '/models/orgUser';
import '/models/positionHistory';
import '/models/presences';
import '/models/rules';
import '/models/runOnServer';
import '/models/settings';
import '/models/swimlanes';
import '/models/tableVisibilityModeSettings';
import '/models/team';
import '/models/translation';
import '/models/triggers';
import '/models/unsavedEdits';
import '/models/userPositionHistory';
import '/models/usersessiondata';
import '/models/users';
import '/models/watchable';
// ----------------------------------------------------------------------------
// 3. Shared runtime services
// ----------------------------------------------------------------------------
import '/imports/reactiveCache';
// Models — server-only exporters
import '/models/csvCreator';
@@ -83,9 +58,6 @@ import '/models/lib/fsHooks/createOnAfterUpload';
import '/models/lib/grid/createBucket';
import '/models/lib/grid/createObjectId';
import '/models/lib/httpStream';
import '/models/lib/meteorMongoIntegration';
import '/models/lib/mongodbConnectionManager';
import '/models/lib/mongodbDriverManager';
import '/models/lib/universalUrlGenerator';
import '/models/lib/userStorageHelpers';
@@ -95,19 +67,38 @@ import '/models/server/ExporterCardPDF';
import '/models/server/ExporterExcelCard';
import '/models/server/ExporterExcel';
import '/models/server/metrics';
import '/server/models/actions';
import '/server/models/activities';
import '/server/models/attachmentStorageSettings';
import '/server/models/boards';
import '/server/models/cards';
import '/server/models/cardComments';
import '/server/models/checklistItems';
import '/server/models/checklists';
import '/server/models/collectionBootstrap';
import '/server/models/customFields';
import '/server/models/integrations';
import '/server/models/lists';
import '/server/models/org';
import '/server/models/settings';
import '/server/models/swimlanes';
import '/server/models/team';
import '/server/models/translation';
import '/server/models/users';
import '/server/models/userPositionHistory';
// ----------------------------------------------------------------------------
// 3. Config (shared)
// 4. Config (shared)
// ----------------------------------------------------------------------------
import '/config/accounts';
import '/config/const';
import '/config/models';
import '/config/query-classes';
import '/config/router';
// router.js is client-only (FlowRouter route actions use client-only modules)
import '/config/search-const';
// ----------------------------------------------------------------------------
// 4. Server — core startup & configuration
// 5. Server — core startup & configuration
// ----------------------------------------------------------------------------
import '/server/00checkStartup';
import '/server/accounts-common';
@@ -117,7 +108,6 @@ import '/server/cors';
import '/server/header-login';
import '/server/max-image-pixel';
import '/server/max-size';
import '/server/mongodb-driver-startup';
import '/server/policy';
import '/server/richer-editor-setting-helper';
import '/server/saml';
@@ -142,6 +132,7 @@ import '/server/cronMigrationManager';
// 7. Server — webhooks
// ----------------------------------------------------------------------------
import '/server/card-opened-webhook';
import '/server/cron/syncedCron';
// ----------------------------------------------------------------------------
// 8. Server — import helpers
@@ -235,6 +226,38 @@ import '/server/rulesHelper';
import '/server/triggersDef';
// ----------------------------------------------------------------------------
// 16. Sandstorm integration
// 16. Server — collection permissions (allow/deny rules)
// ----------------------------------------------------------------------------
import '/server/permissions/accessibilitySettings';
import '/server/permissions/accountSettings';
import '/server/permissions/actions';
import '/server/permissions/announcements';
import '/server/permissions/attachments';
import '/server/permissions/avatars';
import '/server/permissions/boards';
import '/server/permissions/cardCommentReactions';
import '/server/permissions/cardComments';
import '/server/permissions/cards';
import '/server/permissions/checklistItems';
import '/server/permissions/checklists';
import '/server/permissions/customFields';
import '/server/permissions/integrations';
import '/server/permissions/invitationCodes';
import '/server/permissions/lists';
import '/server/permissions/lockoutSettings';
import '/server/permissions/org';
import '/server/permissions/rules';
import '/server/permissions/settings';
import '/server/permissions/swimlanes';
import '/server/permissions/tableVisibilityModeSettings';
import '/server/permissions/team';
import '/server/permissions/translation';
import '/server/permissions/triggers';
import '/server/permissions/unsavedEdits';
import '/server/permissions/userPositionHistory';
import '/server/permissions/users';
// ----------------------------------------------------------------------------
// 17. Sandstorm integration
// ----------------------------------------------------------------------------
import '/sandstorm';
+15 -2
View File
@@ -2,11 +2,24 @@ import { WebApp } from 'meteor/webapp';
import { WebAppInternals } from 'meteor/webapp';
import Settings from '/models/settings';
Meteor.startup(() => {
// Cache the setting since the boilerplate callback is synchronous
let cachedSetting = null;
Meteor.startup(async () => {
// Load initial setting
cachedSetting = await Settings.findOneAsync();
// Keep cache updated reactively
Settings.find().observeChanges({
added() { Settings.findOneAsync().then(s => { cachedSetting = s; }); },
changed() { Settings.findOneAsync().then(s => { cachedSetting = s; }); },
removed() { Settings.findOneAsync().then(s => { cachedSetting = s; }); },
});
// Use Meteor's official API to modify the HTML boilerplate
WebAppInternals.registerBoilerplateDataCallback('wekan-custom-head', (request, data) => {
try {
const setting = Settings.findOne();
const setting = cachedSetting;
// Initialize head array if it doesn't exist
if (!data.head) {
+2 -2
View File
@@ -5,7 +5,7 @@ import { TAPi18n } from '/imports/i18n';
import { ReactiveCache } from '/imports/reactiveCache';
// Main object for email localization utilities
EmailLocalization = {
const EmailLocalization = {
/**
* Send an email using the recipient's preferred language
* @param {Object} options - Standard email sending options plus language options
@@ -55,4 +55,4 @@ EmailLocalization = {
};
// Add module.exports to make it accessible from other files
module.exports = EmailLocalization;
export default EmailLocalization;
+1 -1
View File
@@ -2,7 +2,7 @@
import { expect } from 'chai';
import sinon from 'sinon';
import { Meteor } from 'meteor/meteor';
import '/models/cards';
import Cards from '/models/cards';
// Helpers to access method handlers
const voteHandler = () => Meteor.server.method_handlers['cards.vote'];
+20 -18
View File
@@ -1,46 +1,48 @@
allowIsBoardAdmin = function(userId, board) {
import Boards from '/models/boards';
export function allowIsBoardAdmin(userId, board) {
return board && board.hasAdmin(userId);
};
}
allowIsBoardMember = function(userId, board) {
export function allowIsBoardMember(userId, board) {
return board && board.hasMember(userId);
};
}
allowIsAnyBoardMember = function(userId, boards) {
export function allowIsAnyBoardMember(userId, boards) {
return boards.some(board => {
return board && board.hasMember(userId);
});
};
}
allowIsBoardMemberCommentOnly = function(userId, board) {
export function allowIsBoardMemberCommentOnly(userId, board) {
return board && board.hasMember(userId) && !board.hasReadOnly(userId) && !board.hasReadAssignedOnly(userId) && !board.hasNoComments(userId);
};
}
allowIsBoardMemberNoComments = function(userId, board) {
export function allowIsBoardMemberNoComments(userId, board) {
return board && board.hasMember(userId) && !board.hasNoComments(userId);
};
}
// Check if user has write access to board (can create/edit cards and lists)
allowIsBoardMemberWithWriteAccess = function(userId, board) {
export function allowIsBoardMemberWithWriteAccess(userId, board) {
return board && board.members && board.members.some(e => e.userId === userId && e.isActive && !e.isNoComments && !e.isCommentOnly && !e.isWorker && !e.isReadOnly && !e.isReadAssignedOnly);
};
}
// Check if user has write access via a card's board
allowIsBoardMemberWithWriteAccessByCard = async function(userId, card) {
export async function allowIsBoardMemberWithWriteAccessByCard(userId, card) {
const board = card && await Boards.findOneAsync(card.boardId);
return allowIsBoardMemberWithWriteAccess(userId, board);
};
}
allowIsBoardMemberByCard = async function(userId, card) {
export async function allowIsBoardMemberByCard(userId, card) {
const board = card && await Boards.findOneAsync(card.boardId);
return board && board.hasMember(userId);
};
}
// Policy: can a user update a board's 'sort' field?
// Requirements:
// - user must be authenticated
// - update must include 'sort' field
// - user must be a member of the board
canUpdateBoardSort = function(userId, board, fieldNames) {
export function canUpdateBoardSort(userId, board, fieldNames) {
return !!userId && (fieldNames || []).includes('sort') && allowIsBoardMember(userId, board);
};
}
+6 -10
View File
@@ -1,18 +1,14 @@
// ============================================================================
// WeKan Server Entry Point
//
// CRITICAL: This file uses require() — NOT import.
// With ES module imports, ALL import statements are hoisted and evaluated
// in dependency-graph order (not textual order). This means we cannot
// guarantee that collection2/SimpleSchema load before model files.
//
// With require(), each call completes synchronously before the next:
// 1. collectionHelpers → loads collection2, SimpleSchema, helpers shim
// 2. App code → all models can safely use attachSchema/helpers
// Uses require() to guarantee bootstrap runs before any model code.
// ============================================================================
// 1. Bootstrap — MUST complete before any model code runs
// 1. Helpers polyfill (server no-op for .helpers())
require('/imports/collectionHelpers');
// 2. Load all application code
// 2. Collection2 (server-only)
require('meteor/aldeed:collection2');
// 3. Load all application code
require('/server/imports');
+31 -32
View File
@@ -24,13 +24,13 @@ Meteor.methods({
console.log('Starting duplicate lists fix for all boards...');
}
const allBoards = Boards.find({}).fetch();
const allBoards = await Boards.find({}).fetchAsync();
let totalFixed = 0;
let totalBoardsProcessed = 0;
for (const board of allBoards) {
try {
const result = fixDuplicateListsForBoard(board._id);
const result = await fixDuplicateListsForBoard(board._id);
totalFixed += result.fixed;
totalBoardsProcessed++;
@@ -65,21 +65,21 @@ Meteor.methods({
throw new Meteor.Error('not-authorized');
}
return fixDuplicateListsForBoard(boardId);
return await fixDuplicateListsForBoard(boardId);
}
});
// Helper functions defined outside of Meteor.methods
function fixDuplicateListsForBoard(boardId) {
async function fixDuplicateListsForBoard(boardId) {
if (process.env.DEBUG === 'true') {
console.log(`Fixing duplicate lists for board ${boardId}...`);
}
// First, fix duplicate swimlanes
const swimlaneResult = fixDuplicateSwimlanes(boardId);
const swimlaneResult = await fixDuplicateSwimlanes(boardId);
// Then, fix duplicate lists
const listResult = fixDuplicateLists(boardId);
const listResult = await fixDuplicateLists(boardId);
return {
boardId,
@@ -90,8 +90,8 @@ function fixDuplicateListsForBoard(boardId) {
}
// Helper functions defined outside of Meteor.methods
function fixDuplicateSwimlanes(boardId) {
const swimlanes = Swimlanes.find({ boardId }).fetch();
async function fixDuplicateSwimlanes(boardId) {
const swimlanes = await Swimlanes.find({ boardId }).fetchAsync();
const swimlaneGroups = {};
let fixed = 0;
@@ -105,7 +105,7 @@ function fixDuplicateSwimlanes(boardId) {
});
// For each group with duplicates, keep the oldest and remove the rest
Object.keys(swimlaneGroups).forEach(title => {
for (const title of Object.keys(swimlaneGroups)) {
const group = swimlaneGroups[title];
if (group.length > 1) {
// Sort by creation date, keep the oldest
@@ -118,11 +118,11 @@ function fixDuplicateSwimlanes(boardId) {
}
// Move all lists from duplicate swimlanes to the kept swimlane
removeSwimlanes.forEach(swimlane => {
const lists = Lists.find({ swimlaneId: swimlane._id }).fetch();
lists.forEach(list => {
for (const swimlane of removeSwimlanes) {
const lists = await Lists.find({ swimlaneId: swimlane._id }).fetchAsync();
for (const list of lists) {
// Check if a list with the same title already exists in the kept swimlane
const existingList = Lists.findOne({
const existingList = await Lists.findOneAsync({
boardId,
swimlaneId: keepSwimlane._id,
title: list.title
@@ -130,37 +130,37 @@ function fixDuplicateSwimlanes(boardId) {
if (existingList) {
// Move cards to existing list
Cards.update(
await Cards.updateAsync(
{ listId: list._id },
{ $set: { listId: existingList._id } },
{ multi: true }
);
// Remove duplicate list
Lists.remove(list._id);
await Lists.removeAsync(list._id);
if (process.env.DEBUG === 'true') {
console.log(`Moved cards from duplicate list "${list.title}" to existing list in kept swimlane`);
}
} else {
// Move list to kept swimlane
Lists.update(list._id, { $set: { swimlaneId: keepSwimlane._id } });
await Lists.updateAsync(list._id, { $set: { swimlaneId: keepSwimlane._id } });
if (process.env.DEBUG === 'true') {
console.log(`Moved list "${list.title}" to kept swimlane`);
}
}
});
}
// Remove duplicate swimlane
Swimlanes.remove(swimlane._id);
await Swimlanes.removeAsync(swimlane._id);
fixed++;
});
}
}
});
}
return { fixed };
}
function fixDuplicateLists(boardId) {
const lists = Lists.find({ boardId }).fetch();
async function fixDuplicateLists(boardId) {
const lists = await Lists.find({ boardId }).fetchAsync();
const listGroups = {};
let fixed = 0;
@@ -174,7 +174,7 @@ function fixDuplicateLists(boardId) {
});
// For each group with duplicates, keep the oldest and remove the rest
Object.keys(listGroups).forEach(key => {
for (const key of Object.keys(listGroups)) {
const group = listGroups[key];
if (group.length > 1) {
// Sort by creation date, keep the oldest
@@ -187,22 +187,22 @@ function fixDuplicateLists(boardId) {
}
// Move all cards from duplicate lists to the kept list
removeLists.forEach(list => {
Cards.update(
for (const list of removeLists) {
await Cards.updateAsync(
{ listId: list._id },
{ $set: { listId: keepList._id } },
{ multi: true }
);
// Remove duplicate list
Lists.remove(list._id);
await Lists.removeAsync(list._id);
fixed++;
if (process.env.DEBUG === 'true') {
console.log(`Moved cards from duplicate list "${list.title}" to kept list`);
}
});
}
}
});
}
return { fixed };
}
@@ -217,12 +217,12 @@ Meteor.methods({
throw new Meteor.Error('not-authorized', 'Admin required');
}
const allBoards = Boards.find({}).fetch();
const allBoards = await Boards.find({}).fetchAsync();
const report = [];
for (const board of allBoards) {
const swimlanes = Swimlanes.find({ boardId: board._id }).fetch();
const lists = Lists.find({ boardId: board._id }).fetch();
const swimlanes = await Swimlanes.find({ boardId: board._id }).fetchAsync();
const lists = await Lists.find({ boardId: board._id }).fetchAsync();
// Check for duplicate swimlanes
const swimlaneGroups = {};
@@ -266,4 +266,3 @@ Meteor.methods({
};
}
});
+4 -3
View File
@@ -1,10 +1,11 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
// Method to find locked users and release them if needed
Meteor.methods({
async getLockedUsers() {
// Check if user has admin rights
const userId = Meteor.userId();
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
@@ -52,7 +53,7 @@ Meteor.methods({
async unlockUser(userId) {
// Check if user has admin rights
const adminId = Meteor.userId();
const adminId = this.userId;
if (!adminId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
@@ -82,7 +83,7 @@ Meteor.methods({
async unlockAllUsers() {
// Check if user has admin rights
const adminId = Meteor.userId();
const adminId = this.userId;
if (!adminId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Meteor } from 'meteor/meteor';
import { AccountsLockout } from 'meteor/wekan-accounts-lockout';
import { ReactiveCache } from '/imports/reactiveCache';
import LockoutSettings from '/models/lockoutSettings';
@@ -5,7 +6,7 @@ import LockoutSettings from '/models/lockoutSettings';
Meteor.methods({
async reloadAccountsLockout() {
// Check if user has admin rights
const userId = Meteor.userId();
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
@@ -22,6 +22,7 @@ import Boards from '/models/boards';
import Lists from '/models/lists';
import Cards from '/models/cards';
import Swimlanes from '/models/swimlanes';
import Users from '/models/users';
import Attachments from '/models/attachments';
import { generateUniversalAttachmentUrl, isUniversalFileUrl } from '/models/lib/universalUrlGenerator';
@@ -261,7 +262,7 @@ class ComprehensiveBoardMigration {
});
// Mark board as processed
Boards.update(boardId, {
await Boards.updateAsync(boardId, {
$set: {
comprehensiveMigrationCompleted: true,
comprehensiveMigrationCompletedAt: new Date(),
@@ -329,7 +330,7 @@ class ComprehensiveBoardMigration {
if (!defaultList) {
// Create a default list for this swimlane
const newListId = Lists.insert({
const newListId = await Lists.insertAsync({
title: 'Default',
boardId: boardId,
swimlaneId: swimlaneId,
@@ -347,7 +348,7 @@ class ComprehensiveBoardMigration {
}
if (needsUpdate) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: {
...updates,
modifiedAt: new Date()
@@ -449,14 +450,14 @@ class ComprehensiveBoardMigration {
if (originalList.collapsed) newListData.collapsed = originalList.collapsed;
// Insert the new list
const newListId = Lists.insert(newListData);
const newListId = await Lists.insertAsync(newListData);
targetList = { _id: newListId, ...newListData };
listsCreated++;
}
// Update all cards in this group to use the correct listId
for (const card of cardsInList) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: {
listId: targetList._id,
modifiedAt: new Date()
@@ -484,7 +485,7 @@ class ComprehensiveBoardMigration {
for (const list of lists) {
if (!list.swimlaneId || list.swimlaneId === '') {
// Assign to default swimlane
Lists.update(list._id, {
await Lists.updateAsync(list._id, {
$set: {
swimlaneId: defaultSwimlane._id,
modifiedAt: new Date()
@@ -511,7 +512,7 @@ class ComprehensiveBoardMigration {
if (listCards.length === 0) {
// Remove empty list
Lists.remove(list._id);
await Lists.removeAsync(list._id);
listsRemoved++;
if (process.env.DEBUG === 'true') {
@@ -591,7 +592,7 @@ class ComprehensiveBoardMigration {
if (needsUpdate) {
// Update user's avatar URL
Users.update(user._id, {
await Users.updateAsync(user._id, {
$set: {
'profile.avatarUrl': cleanUrl,
modifiedAt: new Date()
@@ -651,7 +652,7 @@ class ComprehensiveBoardMigration {
if (needsUpdate) {
// Update attachment URL
Attachments.update(attachment._id, {
await Attachments.updateAsync(attachment._id, {
$set: {
url: cleanUrl,
modifiedAt: new Date()
@@ -145,7 +145,7 @@ class DeleteDuplicateEmptyListsMigration {
if (!existingList) {
// Create new per-swimlane list
const newListId = Lists.insert({
const newListId = await Lists.insertAsync({
title: sharedList.title,
boardId: boardId,
swimlaneId: swimlane._id,
@@ -157,7 +157,7 @@ class DeleteDuplicateEmptyListsMigration {
// Move cards to the new list
for (const card of swimlaneCards) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: {
listId: newListId,
swimlaneId: swimlane._id
@@ -171,7 +171,7 @@ class DeleteDuplicateEmptyListsMigration {
} else {
// Move cards to existing per-swimlane list
for (const card of swimlaneCards) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: {
listId: existingList._id,
swimlaneId: swimlane._id
@@ -187,7 +187,7 @@ class DeleteDuplicateEmptyListsMigration {
}
// Remove the shared list (now that all cards are moved)
Lists.remove(sharedList._id);
await Lists.removeAsync(sharedList._id);
listsConverted++;
if (process.env.DEBUG === 'true') {
@@ -253,7 +253,7 @@ class DeleteDuplicateEmptyListsMigration {
}
// All safety checks passed - delete the empty per-swimlane list
Lists.remove(list._id);
await Lists.removeAsync(list._id);
listsDeleted++;
if (process.env.DEBUG === 'true') {
+49 -43
View File
@@ -9,6 +9,12 @@
* This is similar to the existing rescue migration but specifically for swimlaneId validation
*/
import Activities from '/models/activities';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
// Helper collection to track migrations - must be defined first
const Migrations = new Mongo.Collection('migrations');
@@ -38,19 +44,19 @@ export const MIGRATION_VERSION = 1;
/**
* Get or create a "Rescued Data" swimlane for a board
*/
function getOrCreateRescuedSwimlane(boardId) {
const board = Boards.findOne(boardId);
async function getOrCreateRescuedSwimlane(boardId) {
const board = await Boards.findOneAsync(boardId);
if (!board) return null;
// Look for existing rescued data swimlane
let rescuedSwimlane = Swimlanes.findOne({
let rescuedSwimlane = await Swimlanes.findOneAsync({
boardId,
title: { $regex: /rescued.*data/i },
});
if (!rescuedSwimlane) {
// Create a new rescued data swimlane
const swimlaneId = Swimlanes.insert({
const swimlaneId = await Swimlanes.insertAsync({
title: 'Rescued Data (Missing Swimlane)',
boardId,
archived: false,
@@ -59,9 +65,9 @@ function getOrCreateRescuedSwimlane(boardId) {
color: 'red',
});
rescuedSwimlane = Swimlanes.findOne(swimlaneId);
rescuedSwimlane = await Swimlanes.findOneAsync(swimlaneId);
Activities.insert({
await Activities.insertAsync({
userId: 'migration',
type: 'swimlane',
activityType: 'createSwimlane',
@@ -77,29 +83,29 @@ function getOrCreateRescuedSwimlane(boardId) {
/**
* Validate and fix cards without valid swimlaneId
*/
function fixCardsWithoutSwimlaneId() {
async function fixCardsWithoutSwimlaneId() {
let fixedCount = 0;
let rescuedCount = 0;
const cardsWithoutSwimlane = Cards.find({
const cardsWithoutSwimlane = await Cards.find({
$or: [
{ swimlaneId: { $exists: false } },
{ swimlaneId: null },
{ swimlaneId: '' },
],
}).fetch();
}).fetchAsync();
console.log(`Found ${cardsWithoutSwimlane.length} cards without swimlaneId`);
cardsWithoutSwimlane.forEach(card => {
const board = Boards.findOne(card.boardId);
for (const card of cardsWithoutSwimlane) {
const board = await Boards.findOneAsync(card.boardId);
if (!board) {
console.warn(`Card ${card._id} has invalid boardId: ${card.boardId}`);
return;
continue;
}
// Try to get default swimlane
let defaultSwimlane = Swimlanes.findOne({
let defaultSwimlane = await Swimlanes.findOneAsync({
boardId: card.boardId,
type: { $ne: 'template-swimlane' },
archived: false,
@@ -107,25 +113,25 @@ function getOrCreateRescuedSwimlane(boardId) {
if (!defaultSwimlane) {
// No swimlanes at all - create default
const swimlaneId = Swimlanes.insert({
const swimlaneId = await Swimlanes.insertAsync({
title: 'Default',
boardId: card.boardId,
archived: false,
sort: 0,
type: 'swimlane',
});
defaultSwimlane = Swimlanes.findOne(swimlaneId);
defaultSwimlane = await Swimlanes.findOneAsync(swimlaneId);
}
if (defaultSwimlane) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: { swimlaneId: defaultSwimlane._id },
});
fixedCount++;
} else {
console.warn(`Could not find or create default swimlane for card ${card._id}`);
}
});
}
return { fixedCount, rescuedCount };
}
@@ -133,26 +139,26 @@ function getOrCreateRescuedSwimlane(boardId) {
/**
* Validate and fix lists without valid swimlaneId
*/
function fixListsWithoutSwimlaneId() {
async function fixListsWithoutSwimlaneId() {
let fixedCount = 0;
const listsWithoutSwimlane = Lists.find({
const listsWithoutSwimlane = await Lists.find({
$or: [
{ swimlaneId: { $exists: false } },
{ swimlaneId: null },
],
}).fetch();
}).fetchAsync();
console.log(`Found ${listsWithoutSwimlane.length} lists without swimlaneId`);
listsWithoutSwimlane.forEach(list => {
for (const list of listsWithoutSwimlane) {
// Set to empty string for backward compatibility
// (lists can be shared across swimlanes)
Lists.update(list._id, {
await Lists.updateAsync(list._id, {
$set: { swimlaneId: '' },
});
fixedCount++;
});
}
return { fixedCount };
}
@@ -160,27 +166,27 @@ function getOrCreateRescuedSwimlane(boardId) {
/**
* Find and rescue orphaned cards (swimlaneId points to non-existent swimlane)
*/
function rescueOrphanedCards() {
async function rescueOrphanedCards() {
let rescuedCount = 0;
const allCards = Cards.find({}).fetch();
const allCards = await Cards.find({}).fetchAsync();
allCards.forEach(card => {
for (const card of allCards) {
if (!card.swimlaneId) return; // Handled by fixCardsWithoutSwimlaneId
// Check if swimlane exists
const swimlane = Swimlanes.findOne(card.swimlaneId);
const swimlane = await Swimlanes.findOneAsync(card.swimlaneId);
if (!swimlane) {
// Orphaned card - swimlane doesn't exist
const rescuedSwimlane = getOrCreateRescuedSwimlane(card.boardId);
const rescuedSwimlane = await getOrCreateRescuedSwimlane(card.boardId);
if (rescuedSwimlane) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: { swimlaneId: rescuedSwimlane._id },
});
rescuedCount++;
Activities.insert({
await Activities.insertAsync({
userId: 'migration',
type: 'card',
activityType: 'moveCard',
@@ -192,7 +198,7 @@ function getOrCreateRescuedSwimlane(boardId) {
});
}
}
});
}
return { rescuedCount };
}
@@ -203,11 +209,11 @@ function getOrCreateRescuedSwimlane(boardId) {
*/
function addSwimlaneIdValidationHooks() {
// Card insert hook
Cards.before.insert(function(userId, doc) {
Cards.before.insert(async function(userId, doc) {
if (!doc.swimlaneId) {
const board = Boards.findOne(doc.boardId);
const board = await Boards.findOneAsync(doc.boardId);
if (board) {
const defaultSwimlane = Swimlanes.findOne({
const defaultSwimlane = await Swimlanes.findOneAsync({
boardId: doc.boardId,
type: { $ne: 'template-swimlane' },
archived: false,
@@ -217,7 +223,7 @@ function getOrCreateRescuedSwimlane(boardId) {
doc.swimlaneId = defaultSwimlane._id;
} else {
console.warn('No default swimlane found for new card, creating one');
const swimlaneId = Swimlanes.insert({
const swimlaneId = await Swimlanes.insertAsync({
title: 'Default',
boardId: doc.boardId,
archived: false,
@@ -231,14 +237,14 @@ function getOrCreateRescuedSwimlane(boardId) {
});
// Card update hook - ensure swimlaneId is never removed
Cards.before.update(function(userId, doc, fieldNames, modifier) {
Cards.before.update(async function(userId, doc, fieldNames, modifier) {
if (modifier.$unset && modifier.$unset.swimlaneId) {
delete modifier.$unset.swimlaneId;
console.warn('Prevented removal of swimlaneId from card', doc._id);
}
if (modifier.$set && modifier.$set.swimlaneId === null) {
const defaultSwimlane = Swimlanes.findOne({
const defaultSwimlane = await Swimlanes.findOneAsync({
boardId: doc.boardId,
type: { $ne: 'template-swimlane' },
archived: false,
@@ -252,8 +258,8 @@ function getOrCreateRescuedSwimlane(boardId) {
}
// Exported function to run the migration from cron
export function runEnsureValidSwimlaneIdsMigration() {
const existingMigration = Migrations.findOne({ name: MIGRATION_NAME });
export async function runEnsureValidSwimlaneIdsMigration() {
const existingMigration = await Migrations.findOneAsync({ name: MIGRATION_NAME });
if (existingMigration && existingMigration.version >= MIGRATION_VERSION) {
console.log(`Migration ${MIGRATION_NAME} already completed`);
return { alreadyCompleted: true, ...existingMigration.results };
@@ -263,9 +269,9 @@ function getOrCreateRescuedSwimlane(boardId) {
try {
// Run all fix operations
const cardResults = fixCardsWithoutSwimlaneId();
const listResults = fixListsWithoutSwimlaneId();
const rescueResults = rescueOrphanedCards();
const cardResults = await fixCardsWithoutSwimlaneId();
const listResults = await fixListsWithoutSwimlaneId();
const rescueResults = await rescueOrphanedCards();
console.log('Migration results:');
console.log(`- Fixed ${cardResults.fixedCount} cards without swimlaneId`);
@@ -273,7 +279,7 @@ function getOrCreateRescuedSwimlane(boardId) {
console.log(`- Rescued ${rescueResults.rescuedCount} orphaned cards`);
// Record migration completion
Migrations.upsert(
await Migrations.upsertAsync(
{ name: MIGRATION_NAME },
{
$set: {
+3 -3
View File
@@ -162,7 +162,7 @@ class FixAllFileUrlsMigration {
if (cleanUrl && cleanUrl !== avatarUrl) {
// Update user's avatar URL
Users.update(user._id, {
await Users.updateAsync(user._id, {
$set: {
'profile.avatarUrl': cleanUrl,
modifiedAt: new Date()
@@ -203,7 +203,7 @@ class FixAllFileUrlsMigration {
if (cleanUrl && cleanUrl !== attachment.url) {
// Update attachment URL
Attachments.update(attachment._id, {
await Attachments.updateAsync(attachment._id, {
$set: {
url: cleanUrl,
modifiedAt: new Date()
@@ -242,7 +242,7 @@ class FixAllFileUrlsMigration {
if (cleanUrl && cleanUrl !== attachment.url) {
// Update attachment with fixed URL
Attachments.update(attachment._id, {
await Attachments.updateAsync(attachment._id, {
$set: {
url: cleanUrl,
modifiedAt: new Date()
+1 -1
View File
@@ -100,7 +100,7 @@ class FixAvatarUrlsMigration {
if (needsUpdate) {
// Update user's avatar URL
Users.update(user._id, {
await Users.updateAsync(user._id, {
$set: {
'profile.avatarUrl': cleanUrl,
modifiedAt: new Date()
@@ -165,7 +165,7 @@ class FixMissingListsMigration {
if (originalList.collapsed) newListData.collapsed = originalList.collapsed;
// Insert the new list
const newListId = Lists.insert(newListData);
const newListId = await Lists.insertAsync(newListData);
targetList = { _id: newListId, ...newListData };
createdLists++;
@@ -176,7 +176,7 @@ class FixMissingListsMigration {
// Update all cards in this group to use the correct listId
for (const card of cardsInList) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: {
listId: targetList._id,
modifiedAt: new Date()
@@ -188,7 +188,7 @@ class FixMissingListsMigration {
}
// Mark board as processed
Boards.update(boardId, {
await Boards.updateAsync(boardId, {
$set: {
fixMissingListsCompleted: true,
fixMissingListsCompletedAt: new Date()
+3 -2
View File
@@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { getOldAttachmentData, getOldAttachmentDataBuffer } from '/models/lib/attachmentBackwardCompatibility';
import Attachments from '/models/attachments';
/**
* Migration script to convert old CollectionFS attachments to new Meteor-Files structure
@@ -43,7 +44,7 @@ if (Meteor.isServer) {
type: oldAttachment.type
});
const uploader = Attachments.insert({
const uploader = await Attachments.insertAsync({
file: fileObj,
meta: oldAttachment.meta,
isBase64: false,
@@ -88,7 +89,7 @@ if (Meteor.isServer) {
const oldAttachments = await ReactiveCache.getAttachments({ 'meta.cardId': cardId });
for (const attachment of oldAttachments) {
const result = Meteor.call('migrateAttachment', attachment._id);
const result = await Meteor.callAsync('migrateAttachment', attachment._id);
if (result.success) {
results.success++;
} else {
+12 -7
View File
@@ -15,6 +15,11 @@ import Lists from '/models/lists';
import Cards from '/models/cards';
import Swimlanes from '/models/swimlanes';
function getTranslatedString(key, fallback, options) {
const translated = TAPi18n.__(key, options);
return typeof translated === 'string' ? translated : fallback;
}
class RestoreAllArchivedMigration {
constructor() {
this.name = 'restoreAllArchived';
@@ -66,7 +71,7 @@ class RestoreAllArchivedMigration {
// Restore all archived swimlanes
for (const swimlane of archivedSwimlanes) {
Swimlanes.update(swimlane._id, {
await Swimlanes.updateAsync(swimlane._id, {
$set: {
archived: false,
updatedAt: new Date()
@@ -93,8 +98,8 @@ class RestoreAllArchivedMigration {
if (!targetSwimlane) {
// No active swimlane found, create default
const swimlaneId = Swimlanes.insert({
title: TAPi18n.__('default'),
const swimlaneId = await Swimlanes.insertAsync({
title: getTranslatedString('default', 'Default'),
boardId: boardId,
sort: 0,
createdAt: new Date(),
@@ -112,7 +117,7 @@ class RestoreAllArchivedMigration {
}
}
Lists.update(list._id, {
await Lists.updateAsync(list._id, {
$set: updateFields
});
results.listsRestored++;
@@ -144,8 +149,8 @@ class RestoreAllArchivedMigration {
// No active list found, create one
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
const listId = Lists.insert({
title: TAPi18n.__('default'),
const listId = await Lists.insertAsync({
title: getTranslatedString('default', 'Default'),
boardId: boardId,
swimlaneId: defaultSwimlane._id,
sort: 0,
@@ -188,7 +193,7 @@ class RestoreAllArchivedMigration {
}
}
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: updateFields
});
results.cardsRestored++;
+5 -5
View File
@@ -107,7 +107,7 @@ class RestoreLostCardsMigration {
// Find or create "Lost Cards" swimlane (only if there is actual work)
let lostCardsSwimlane = swimlanes.find(s => s.title === TAPi18n.__('lost-cards'));
if (!lostCardsSwimlane) {
const swimlaneId = Swimlanes.insert({
const swimlaneId = await Swimlanes.insertAsync({
title: TAPi18n.__('lost-cards'),
boardId: boardId,
sort: 999999, // Put at the end
@@ -126,7 +126,7 @@ class RestoreLostCardsMigration {
// Restore lost lists (lists without swimlaneId)
if (hasListsWork) {
for (const list of lostLists) {
Lists.update(list._id, {
await Lists.updateAsync(list._id, {
$set: {
swimlaneId: lostCardsSwimlane._id,
updatedAt: new Date()
@@ -147,7 +147,7 @@ class RestoreLostCardsMigration {
l.title === TAPi18n.__('lost-cards-list')
);
if (!defaultList) {
const listId = Lists.insert({
const listId = await Lists.insertAsync({
title: TAPi18n.__('lost-cards-list'),
boardId: boardId,
swimlaneId: lostCardsSwimlane._id,
@@ -169,7 +169,7 @@ class RestoreLostCardsMigration {
const updateFields = { updatedAt: new Date() };
if (!card.swimlaneId) updateFields.swimlaneId = lostCardsSwimlane._id;
if (!card.listId) updateFields.listId = defaultList._id;
Cards.update(card._id, { $set: updateFields });
await Cards.updateAsync(card._id, { $set: updateFields });
results.cardsRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored lost card: ${card.title}`);
@@ -178,7 +178,7 @@ class RestoreLostCardsMigration {
// Restore orphaned cards (cards whose list doesn't exist)
for (const card of orphanedCards) {
Cards.update(card._id, {
await Cards.updateAsync(card._id, {
$set: {
listId: defaultList._id,
swimlaneId: lostCardsSwimlane._id,
+6
View File
@@ -0,0 +1,6 @@
import { Meteor } from 'meteor/meteor';
import Actions from '/models/actions';
Meteor.startup(async () => {
await Actions._collection.createIndexAsync({ modifiedAt: -1 });
});
+344
View File
@@ -0,0 +1,344 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { findWhere, where } from '/imports/lib/collectionHelpers';
import Activities from '/models/activities';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Integrations from '/models/integrations';
import { RulesHelper } from '/server/rulesHelper';
import { Notifications } from '/server/notifications/notifications';
function normalizeActivityText(value, fallback = '') {
return typeof value === 'string' ? value : fallback;
}
function getActivityUserName(user, fallback = '') {
if (!user) {
return fallback;
}
return normalizeActivityText(
user.getName?.() || user.username || user.profile?.fullname || user.profile?.name,
fallback,
);
}
Activities.after.insert(async (userId, doc) => {
const activity = Activities._transform(doc);
try {
await RulesHelper.executeRules(activity);
} catch (e) {
console.error('RulesHelper.executeRules error for activity', doc._id, e);
}
});
Meteor.startup(async () => {
await Activities._collection.createIndexAsync({ createdAt: -1 });
await Activities._collection.createIndexAsync({ modifiedAt: -1 });
await Activities._collection.createIndexAsync({ cardId: 1, createdAt: -1 });
await Activities._collection.createIndexAsync({ boardId: 1, createdAt: -1 });
await Activities._collection.createIndexAsync(
{ commentId: 1 },
{ partialFilterExpression: { commentId: { $exists: true } } },
);
await Activities._collection.createIndexAsync(
{ attachmentId: 1 },
{ partialFilterExpression: { attachmentId: { $exists: true } } },
);
await Activities._collection.createIndexAsync(
{ customFieldId: 1 },
{ partialFilterExpression: { customFieldId: { $exists: true } } },
);
});
Activities.after.insert(async (userId, doc) => {
const activity = Activities._transform(doc);
let participants = [];
let watchers = [];
let title = 'act-activity-notify';
const board = activity.boardId
? (await ReactiveCache.getBoard(activity.boardId)) || (await Boards.findOneAsync(activity.boardId))
: null;
const description = `act-${activity.activityType}`;
const params = {
activityId: activity._id,
};
if (activity.userId) {
const user = await activity.user();
if (user) {
params.user = getActivityUserName(user, activity.userId);
if (user.emails) {
params.userEmails = user.emails;
}
params.userId = activity.userId;
}
}
if (activity.boardId) {
params.board = normalizeActivityText(board?.title);
title = 'act-withBoardTitle';
if (board && typeof board.absoluteUrl === 'function') {
params.url = board.absoluteUrl();
}
params.boardId = activity.boardId;
}
if (activity.oldBoardId) {
const oldBoard = await activity.oldBoard();
if (oldBoard) {
watchers = [...new Set([...watchers, ...(oldBoard.watchers || [])])];
params.oldBoard = normalizeActivityText(oldBoard.title);
params.oldBoardId = activity.oldBoardId;
}
}
if (activity.memberId) {
participants = [...new Set([...participants, activity.memberId])];
params.member = getActivityUserName(await activity.member(), activity.memberId);
}
if (activity.listId) {
const list = await activity.list();
if (list) {
if (list.watchers !== undefined) {
watchers = [...new Set([...watchers, ...(list.watchers || [])])];
}
params.list = normalizeActivityText(list.title);
params.listId = activity.listId;
}
}
if (activity.oldListId) {
const oldList = await activity.oldList();
if (oldList) {
watchers = [...new Set([...watchers, ...(oldList.watchers || [])])];
params.oldList = normalizeActivityText(oldList.title);
params.oldListId = activity.oldListId;
}
}
if (activity.oldSwimlaneId) {
const oldSwimlane = await activity.oldSwimlane();
if (oldSwimlane) {
watchers = [...new Set([...watchers, ...(oldSwimlane.watchers || [])])];
params.oldSwimlane = normalizeActivityText(oldSwimlane.title);
params.oldSwimlaneId = activity.oldSwimlaneId;
}
}
if (activity.cardId) {
const card = (await activity.card()) || (await Cards.findOneAsync(activity.cardId));
if (card) {
participants = [...new Set([...participants, card.userId, ...(card.members || [])])];
watchers = [...new Set([...watchers, ...(card.watchers || [])])];
params.card = normalizeActivityText(card.title);
title = 'act-withCardTitle';
if (typeof card.absoluteUrl === 'function') {
params.url = card.absoluteUrl();
}
params.cardId = activity.cardId;
}
}
if (activity.swimlaneId) {
const swimlane = await activity.swimlane();
params.swimlane = normalizeActivityText(swimlane?.title);
params.swimlaneId = activity.swimlaneId;
}
if (activity.commentId) {
const comment = await activity.comment();
params.comment = normalizeActivityText(comment.text);
let hasMentions = false;
if (board) {
const knownUsers = [];
for (const member of board.members) {
const u = await ReactiveCache.getUser(member.userId);
if (u) {
member.username = u.username;
member.emails = u.emails;
}
knownUsers.push(member);
}
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.@-]+))/gi;
let currentMention;
while ((currentMention = mentionRegex.exec(params.comment)) !== null) {
const [, quoteduser, simple] = currentMention;
const username = quoteduser || simple;
if (activity.boardId && username === 'board_members') {
const validUserIds = [];
for (const u of knownUsers) {
const user = await ReactiveCache.getUser(u.userId);
if (user && user._id) {
validUserIds.push(u.userId);
}
}
watchers = [...new Set([...watchers, ...validUserIds])];
title = 'act-atUserComment';
hasMentions = true;
} else if (activity.boardId && username === 'board_assignees') {
const allCards = await ReactiveCache.getCards({ boardId: activity.boardId });
const assigneeIds = [];
for (const card of allCards) {
if (card.assignees && card.assignees.length > 0) {
for (const assigneeId of card.assignees) {
const user = await ReactiveCache.getUser(assigneeId);
if (user && findWhere(knownUsers, { userId: assigneeId })) {
assigneeIds.push(assigneeId);
}
}
}
}
watchers = [...new Set([...watchers, ...assigneeIds])];
title = 'act-atUserComment';
hasMentions = true;
} else if (activity.cardId && username === 'card_members') {
const card = await activity.card();
if (card && card.members && card.members.length > 0) {
const validMembers = [];
for (const memberId of card.members) {
const user = await ReactiveCache.getUser(memberId);
if (user && user._id && findWhere(knownUsers, { userId: memberId })) {
validMembers.push(memberId);
}
}
watchers = [...new Set([...watchers, ...validMembers])];
}
title = 'act-atUserComment';
hasMentions = true;
} else if (activity.cardId && username === 'card_assignees') {
const card = await activity.card();
if (card && card.assignees && card.assignees.length > 0) {
const validAssignees = [];
for (const assigneeId of card.assignees) {
const user = await ReactiveCache.getUser(assigneeId);
if (user && user._id && findWhere(knownUsers, { userId: assigneeId })) {
validAssignees.push(assigneeId);
}
}
watchers = [...new Set([...watchers, ...validAssignees])];
}
title = 'act-atUserComment';
hasMentions = true;
} else {
const atUser = findWhere(knownUsers, { username });
if (!atUser) {
continue;
}
params.atUsername = username;
params.atEmails = atUser.emails;
title = 'act-atUserComment';
watchers = [...new Set([...watchers, atUser.userId])];
hasMentions = true;
}
}
}
params.commentId = comment._id;
params.hasMentions = hasMentions;
}
if (activity.attachmentId) {
params.attachment = activity.attachmentName;
params.attachmentId = activity.attachmentId;
}
if (activity.checklistId) {
const checklist = await activity.checklist();
if (checklist?.title) {
params.checklist = normalizeActivityText(checklist.title);
}
}
if (activity.checklistItemId) {
const checklistItem = await activity.checklistItem();
if (checklistItem?.title) {
params.checklistItem = normalizeActivityText(checklistItem.title);
}
}
if (activity.customFieldId) {
const customField = await activity.customField();
if (customField) {
if (customField.name) {
params.customField = normalizeActivityText(customField.name);
}
if (activity.value) {
params.customFieldValue = activity.value;
}
}
}
if (activity.labelId) {
const label = await activity.label();
if (label) {
if (label.name) {
params.label = normalizeActivityText(label.name);
} else if (label.color) {
params.label = normalizeActivityText(label.color);
}
if (label._id) {
params.labelId = label._id;
}
}
}
if ((!activity.timeKey || activity.timeKey === 'dueAt') && activity.timeValue) {
title = activity.timeOldValue ? 'act-withDue' : 'act-newDue';
}
['timeValue', 'timeOldValue'].forEach((key) => {
const value = activity[key];
if (value) params[key] = value;
});
if (board) {
const activeMemberIds = (board.members || []).filter(m => m.isActive === true).map(m => m.userId);
const BIGEVENTS = process.env.BIGEVENTS_PATTERN;
if (BIGEVENTS) {
try {
const atype = activity.activityType;
if (new RegExp(BIGEVENTS).exec(atype)) {
watchers = [...new Set([...watchers, ...activeMemberIds])];
}
} catch (e) {}
}
const watchingUsers = where(board.watchers, { level: 'watching' }).map(x => x.userId);
const trackingUsers = where(board.watchers, { level: 'tracking' }).map(x => x.userId);
if (!params.hasMentions) {
watchers = [...new Set([
...watchers,
...watchingUsers,
...participants.filter(x => trackingUsers.includes(x)),
])];
}
watchers = watchers.filter(x => activeMemberIds.includes(x));
}
(await Notifications.getUsers(watchers)).forEach((user) => {
if (!user || !user._id) return;
const isSelfMention = user._id === userId && title === 'act-atUserComment';
if (user._id !== userId || isSelfMention) {
Notifications.notify(user, title, description, params);
}
});
const integrations = await ReactiveCache.getIntegrations({
boardId: { $in: [board._id, Integrations.Const.GLOBAL_WEBHOOK_ID] },
enabled: true,
activities: { $in: [description, 'all'] },
});
if (integrations.length > 0) {
params.watchers = watchers;
integrations.forEach((integration) => {
Meteor.call('outgoingWebhooks', integration, description, params, () => {
return;
});
});
}
});
+127
View File
@@ -0,0 +1,127 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
import {
STORAGE_NAME_FILESYSTEM,
STORAGE_NAME_GRIDFS,
} from '/models/lib/fileStoreConstants';
Meteor.methods({
async getAttachmentStorageSettings() {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
let settings = await AttachmentStorageSettings.findOneAsync({});
if (!settings) {
settings = {
defaultStorage: STORAGE_NAME_FILESYSTEM,
storageConfig: {
filesystem: {
enabled: true,
path: process.env.WRITABLE_PATH ? `${process.env.WRITABLE_PATH}/attachments` : '/data/attachments',
},
gridfs: {
enabled: true,
},
},
uploadSettings: {
maxFileSize: process.env.ATTACHMENTS_UPLOAD_MAX_SIZE
? parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) : 0,
allowedMimeTypes: process.env.ATTACHMENTS_UPLOAD_MIME_TYPES
? process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',').map(t => t.trim()) : [],
},
migrationSettings: {
autoMigrate: false,
batchSize: 10,
delayMs: 1000,
cpuThreshold: 70,
},
createdBy: this.userId,
updatedBy: this.userId,
};
await AttachmentStorageSettings.insertAsync(settings);
settings = await AttachmentStorageSettings.findOneAsync({});
}
return settings;
},
async updateAttachmentStorageSettings(settings) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
AttachmentStorageSettings.simpleSchema().validate(settings);
return AttachmentStorageSettings.upsertAsync(
{},
{
$set: {
...settings,
updatedBy: this.userId,
updatedAt: new Date(),
},
},
);
},
async getDefaultAttachmentStorage() {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const settings = await AttachmentStorageSettings.findOneAsync({});
return settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
},
async setDefaultAttachmentStorage(storageName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS].includes(storageName)) {
throw new Meteor.Error('invalid-storage', 'Invalid storage backend');
}
return AttachmentStorageSettings.upsertAsync(
{},
{
$set: {
defaultStorage: storageName,
updatedBy: this.userId,
updatedAt: new Date(),
},
},
);
},
});
Meteor.publish('attachmentStorageSettings', async function() {
if (!this.userId) {
return this.ready();
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
return this.ready();
}
return AttachmentStorageSettings.find({});
});
+756
View File
@@ -0,0 +1,756 @@
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { check } from 'meteor/check';
import { Random } from 'meteor/random';
import { Authentication } from '/server/authentication';
import { sendJsonResult } from '/server/apiMiddleware';
import { allowIsBoardAdmin } from '/server/lib/utils';
import { ReactiveCache } from '/imports/reactiveCache';
import Activities from '/models/activities';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Integrations from '/models/integrations';
import Lists from '/models/lists';
import Rules from '/models/rules';
import Swimlanes from '/models/swimlanes';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import Triggers from '/models/triggers';
import Users from '/models/users';
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
function getTranslatedString(key, fallback, options) {
const i18n = getTAPi18n && getTAPi18n();
if (!i18n || !i18n.i18n) {
return fallback;
}
const translated = i18n.__(key, options);
return typeof translated === 'string' ? translated : fallback;
}
async function boardRemover(doc) {
for (const element of [
Cards,
Lists,
Swimlanes,
Integrations,
Rules,
Activities,
Triggers,
]) {
await element.removeAsync({ boardId: doc._id });
}
}
const foreachRemovedMember = (doc, modifier, callback) => {
Object.keys(modifier).forEach(set => {
if (modifier[set] !== false) {
return;
}
const parts = set.split('.');
if (
parts.length === 3 &&
parts[0] === 'members' &&
parts[2] === 'isActive'
) {
callback(doc.members[parts[1]].userId);
}
});
};
Meteor.methods({
async createBoardWithInitialSwimlanes(payload) {
check(
payload,
Match.ObjectIncluding({
title: String,
slug: String,
permission: Match.Maybe(String),
type: Match.Maybe(String),
migrationVersion: Match.Maybe(Number),
swimlanes: Match.Maybe(Array),
}),
);
const {
title,
slug,
permission = 'private',
type = 'board',
migrationVersion = 1,
swimlanes = [],
} = payload;
for (const swimlane of swimlanes) {
check(swimlane, Object);
check(swimlane.title, String);
check(swimlane.sort, Match.Maybe(Number));
check(swimlane.type, Match.Maybe(String));
}
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const boardId = await Boards.insertAsync({
title,
slug,
permission,
type,
migrationVersion,
members: [
{
userId: this.userId,
isAdmin: true,
isActive: true,
isNoComments: false,
isCommentOnly: false,
isWorker: false,
},
],
});
for (const swimlane of swimlanes) {
await Swimlanes.insertAsync({
title: swimlane.title,
boardId,
sort: swimlane.sort,
type: swimlane.type,
});
}
return boardId;
},
async getBackgroundImageURL(boardId) {
check(boardId, String);
return await ReactiveCache.getBoard(boardId, {}, { backgroundImageUrl: 1 });
},
async quitBoard(boardId) {
check(boardId, String);
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('error-board-doesNotExist');
}
const userId = this.userId;
const index = board.memberIndex(userId);
if (index < 0) {
throw new Meteor.Error('error-board-notAMember');
}
await board.removeMember(userId);
return true;
},
async acceptInvite(boardId) {
check(boardId, String);
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('error-board-doesNotExist');
}
await Meteor.users.updateAsync(this.userId, {
$pull: {
'profile.invitedBoards': boardId,
},
});
await Boards.updateAsync(
{
_id: boardId,
'members.userId': this.userId,
},
{
$set: {
'members.$.isActive': true,
modifiedAt: new Date(),
},
},
);
},
async myLabelNames() {
let names = [];
const boards = await Boards.userBoards(this.userId);
for (const board of boards) {
if (board.labels !== undefined) {
names = names.concat(
board.labels
.filter(label => !!label.name)
.map(label => label.name),
);
}
}
return [...new Set(names)].sort();
},
async myBoardNames() {
const boards = await Boards.userBoards(this.userId);
return [...new Set(boards.map(board => board.title))].sort();
},
async setAllBoardsHideActivities() {
const currentUser = await ReactiveCache.getCurrentUser();
if (!(currentUser || {}).isAdmin) {
return false;
}
await Boards.updateAsync(
{
showActivities: true,
},
{
$set: {
showActivities: false,
},
},
{
multi: true,
},
);
return true;
},
async archiveBoard(boardId) {
check(boardId, String);
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('error-board-doesNotExist');
}
const userId = this.userId;
const index = board.memberIndex(userId);
if (index < 0) {
throw new Meteor.Error('error-board-notAMember');
}
await board.archive();
return true;
},
async setBoardOrgs(boardOrgsArray, currBoardId) {
check(boardOrgsArray, Array);
check(currBoardId, String);
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in to perform this action.');
}
const board = await ReactiveCache.getBoard(currBoardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found.');
}
if (!allowIsBoardAdmin(userId, board)) {
throw new Meteor.Error('not-authorized', 'You must be a board admin to perform this action.');
}
for (const org of boardOrgsArray) {
check(org.orgId, String);
check(org.orgDisplayName, String);
check(org.isActive, Boolean);
}
await Boards.updateAsync(currBoardId, {
$set: {
orgs: boardOrgsArray,
},
});
},
async setBoardTeams(boardTeamsArray, membersArray, currBoardId) {
check(boardTeamsArray, Array);
check(membersArray, Array);
check(currBoardId, String);
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in to perform this action.');
}
const board = await ReactiveCache.getBoard(currBoardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found.');
}
if (!allowIsBoardAdmin(userId, board)) {
throw new Meteor.Error('not-authorized', 'You must be a board admin to perform this action.');
}
for (const team of boardTeamsArray) {
check(team.teamId, String);
check(team.teamDisplayName, String);
check(team.isActive, Boolean);
}
for (const member of membersArray) {
check(member.userId, String);
check(member.isAdmin, Boolean);
check(member.isActive, Boolean);
if (member.isNoComments !== undefined) check(member.isNoComments, Boolean);
if (member.isCommentOnly !== undefined) check(member.isCommentOnly, Boolean);
if (member.isWorker !== undefined) check(member.isWorker, Boolean);
if (member.isNormalAssignedOnly !== undefined) check(member.isNormalAssignedOnly, Boolean);
if (member.isCommentAssignedOnly !== undefined) check(member.isCommentAssignedOnly, Boolean);
if (member.isReadOnly !== undefined) check(member.isReadOnly, Boolean);
if (member.isReadAssignedOnly !== undefined) check(member.isReadAssignedOnly, Boolean);
}
await Boards.updateAsync(currBoardId, {
$set: {
members: membersArray,
teams: boardTeamsArray,
},
});
},
});
Boards.before.insert(async (userId, doc) => {
const lastBoard = await ReactiveCache.getBoard(
{ sort: { $exists: true } },
{ sort: { sort: -1 } },
);
if (lastBoard && typeof lastBoard.sort !== 'undefined') {
doc.sort = lastBoard.sort + 1;
}
});
Meteor.startup(async () => {
await Boards._collection.createIndexAsync({ modifiedAt: -1 });
await Boards._collection.createIndexAsync(
{
_id: 1,
'members.userId': 1,
},
{ unique: true },
);
await Boards._collection.createIndexAsync({ 'members.userId': 1 });
});
Boards.after.insert(async (userId, doc) => {
await Activities.insertAsync({
userId,
type: 'board',
activityTypeId: doc._id,
activityType: 'createBoard',
boardId: doc._id,
});
});
Boards.after.update(async (userId, doc, fieldNames, modifier) => {
if (
!(fieldNames || []).includes('labels') ||
!modifier.$pull ||
!modifier.$pull.labels ||
!modifier.$pull.labels._id
) {
return;
}
const removedLabelId = modifier.$pull.labels._id;
await Cards.updateAsync(
{ boardId: doc._id },
{
$pull: {
labelIds: removedLabelId,
},
},
{ multi: true },
);
});
Boards.before.update((userId, doc, fieldNames, modifier) => {
if (!(fieldNames || []).includes('members') || !modifier.$set) {
return;
}
const boardId = doc._id;
foreachRemovedMember(doc, modifier.$set, async memberId => {
await Cards.updateAsync(
{ boardId },
{
$pull: {
members: memberId,
watchers: memberId,
},
},
{ multi: true },
);
await Lists.updateAsync(
{ boardId },
{
$pull: {
watchers: memberId,
},
},
{ multi: true },
);
const board = Boards._transform(doc);
await board.setWatcher(memberId, false);
if (!board.isPublic()) {
await Users.updateAsync(memberId, {
$pull: {
'profile.starredBoards': boardId,
},
});
}
});
});
Boards.before.remove(async (userId, doc) => {
await boardRemover(doc);
await Activities.insertAsync({
userId,
type: 'board',
activityTypeId: doc._id,
activityType: 'removeBoard',
boardId: doc._id,
});
});
Boards.after.update(async (userId, doc, fieldNames, modifier) => {
if (fieldNames.includes('title')) {
await Activities.insertAsync({
userId,
type: 'board',
activityType: 'changedBoardTitle',
boardId: doc._id,
title: doc.title,
});
}
if (!(fieldNames || []).includes('members')) {
return;
}
if (modifier.$push && modifier.$push.members) {
const memberId = modifier.$push.members.userId;
await Activities.insertAsync({
userId,
memberId,
type: 'member',
activityType: 'addBoardMember',
boardId: doc._id,
});
}
if (modifier.$set) {
const removedMemberIds = [];
foreachRemovedMember(doc, modifier.$set, memberId => {
removedMemberIds.push(memberId);
});
for (const memberId of removedMemberIds) {
await Activities.insertAsync({
userId,
memberId,
type: 'member',
activityType: 'removeBoardMember',
boardId: doc._id,
});
}
}
});
WebApp.handlers.get('/api/users/:userId/boards', async function(req, res) {
try {
Authentication.checkLoggedIn(req.userId);
const paramUserId = req.params.userId;
Authentication.checkAdminOrCondition(req.userId, req.userId === paramUserId);
const boards = await ReactiveCache.getBoards(
{
archived: false,
'members.userId': paramUserId,
},
{
sort: { sort: 1 },
},
);
const data = boards.map(board => ({
_id: board._id,
title: board.title,
}));
sendJsonResult(res, { code: 200, data });
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.get('/api/boards', async function(req, res) {
try {
Authentication.checkUserId(req.userId);
const boards = await ReactiveCache.getBoards(
{ permission: 'public' },
{
sort: { sort: 1 },
},
);
sendJsonResult(res, {
code: 200,
data: boards.map(doc => ({
_id: doc._id,
title: doc.title,
})),
});
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.get('/api/boards_count', async function(req, res) {
try {
Authentication.checkUserId(req.userId);
const privateBoards = await ReactiveCache.getBoards({ permission: 'private' });
const publicBoards = await ReactiveCache.getBoards({ permission: 'public' });
sendJsonResult(res, {
code: 200,
data: {
private: privateBoards.length,
public: publicBoards.length,
},
});
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.get('/api/boards/:boardId', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const board = await ReactiveCache.getBoard(paramBoardId);
sendJsonResult(res, {
code: 200,
data: board,
});
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.post('/api/boards', async function(req, res) {
try {
Authentication.checkLoggedIn(req.userId);
const allowPrivateOnly =
(await TableVisibilityModeSettings.findOneAsync('tableVisibilityMode-allowPrivateOnly'))?.booleanValue;
const permission = allowPrivateOnly ? 'private' : (req.body.permission || 'private');
const id = await Boards.insertAsync({
title: req.body.title,
members: [
{
userId: req.body.owner,
isAdmin: req.body.isAdmin || true,
isActive: req.body.isActive || true,
isNoComments: req.body.isNoComments || false,
isCommentOnly: req.body.isCommentOnly || false,
isWorker: req.body.isWorker || false,
},
],
permission,
color: req.body.color || 'belize',
migrationVersion: 1,
});
const swimlaneId = await Swimlanes.insertAsync({
title: getTranslatedString('default', 'Default'),
boardId: id,
});
sendJsonResult(res, {
code: 200,
data: {
_id: id,
defaultSwimlaneId: swimlaneId,
},
});
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.delete('/api/boards/:boardId', async function(req, res) {
try {
Authentication.checkUserId(req.userId);
const id = req.params.boardId;
await Boards.removeAsync({ _id: id });
sendJsonResult(res, {
code: 200,
data: {
_id: id,
},
});
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.put('/api/boards/:boardId/title', async function(req, res) {
try {
const boardId = req.params.boardId;
Authentication.checkBoardWriteAccess(req.userId, boardId);
const title = req.body.title;
await Boards.direct.updateAsync({ _id: boardId }, { $set: { title } });
sendJsonResult(res, {
code: 200,
data: {
_id: boardId,
title,
},
});
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.put('/api/boards/:boardId/labels', async function(req, res) {
const id = req.params.boardId;
Authentication.checkBoardWriteAccess(req.userId, id);
try {
if (Object.prototype.hasOwnProperty.call(req.body, 'label')) {
const board = await ReactiveCache.getBoard(id);
const color = req.body.label.color;
const name = req.body.label.name;
const labelId = Random.id(6);
if (!board.getLabel(name, color)) {
await Boards.direct.updateAsync(
{ _id: id },
{ $push: { labels: { _id: labelId, name, color } } },
);
sendJsonResult(res, {
code: 200,
data: labelId,
});
} else {
sendJsonResult(res, {
code: 200,
});
}
}
} catch (error) {
sendJsonResult(res, {
data: error,
});
}
});
WebApp.handlers.post('/api/boards/:boardId/copy', async function(req, res) {
const id = req.params.boardId;
const board = await ReactiveCache.getBoard(id);
const adminAccess = board.members.some(e => e.userId === req.userId && e.isAdmin);
Authentication.checkAdminOrCondition(req.userId, adminAccess);
try {
board.title = req.body.title || await Boards.uniqueTitle(board.title);
const ret = await board.copy();
sendJsonResult(res, {
code: 200,
data: ret,
});
} catch (error) {
sendJsonResult(res, {
data: error,
});
}
});
WebApp.handlers.post('/api/boards/:boardId/members/:memberId', async function(req, res) {
try {
Authentication.checkUserId(req.userId);
const boardId = req.params.boardId;
const memberId = req.params.memberId;
const {
isAdmin,
isNoComments,
isCommentOnly,
isWorker,
isNormalAssignedOnly,
isCommentAssignedOnly,
isReadOnly,
isReadAssignedOnly,
} = req.body;
const board = await ReactiveCache.getBoard(boardId);
function isTrue(data) {
try {
return data.toLowerCase() === 'true';
} catch (error) {
return data;
}
}
const query = await board.setMemberPermission(
memberId,
isTrue(isAdmin),
isTrue(isNoComments),
isTrue(isCommentOnly),
isTrue(isWorker),
isTrue(isNormalAssignedOnly),
isTrue(isCommentAssignedOnly),
isTrue(isReadOnly),
isTrue(isReadAssignedOnly),
req.userId,
);
sendJsonResult(res, {
code: 200,
data: query,
});
} catch (error) {
sendJsonResult(res, {
code: 200,
data: error,
});
}
});
WebApp.handlers.get('/api/boards/:boardId/attachments', async function(req, res) {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
const attachments = await ReactiveCache.getAttachments(
{ 'meta.boardId': paramBoardId },
{},
true,
);
sendJsonResult(res, {
code: 200,
data: attachments.each().map(attachment => ({
attachmentId: attachment._id,
attachmentName: attachment.name,
attachmentType: attachment.type,
url: attachment.link(),
urlDownload: `${attachment.link()}?download=true&token=`,
boardId: attachment.meta.boardId,
swimlaneId: attachment.meta.swimlaneId,
listId: attachment.meta.listId,
cardId: attachment.meta.cardId,
})),
});
});
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more