Files
wekan/server/models/userPositionHistory.js
T
Lauri Ojansivu 39f3c89b0a Fixed upgrade crash.
Thanks to poc-sm and Claude !

Fixes #6367
2026-06-06 02:53:58 +03:00

230 lines
6.7 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import Boards from '/models/boards';
import UserPositionHistory from '/models/userPositionHistory';
import { ReactiveCache } from '/imports/reactiveCache';
import { ensureIndex } from '/server/lib/mongoStartup';
// Reject callers who are not allowed to see the board. Mirrors the guard used
// by the sibling positionHistory.track* methods (server/methods/positionHistory.js).
// Without this, any authenticated user could create/read position-history
// checkpoints scoped to an arbitrary board they have no access to (the same
// PositionHistoryBleed class listed in the Hall of Fame).
const requireBoardVisible = async (userId, boardId) => {
const board = await ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
};
Meteor.startup(async () => {
await ensureIndex(UserPositionHistory, { userId: 1, boardId: 1, createdAt: -1 });
await ensureIndex(UserPositionHistory, { userId: 1, entityType: 1, entityId: 1 });
await ensureIndex(UserPositionHistory, { userId: 1, isCheckpoint: 1 });
await ensureIndex(UserPositionHistory, { batchId: 1 });
await ensureIndex(UserPositionHistory, { createdAt: 1 });
});
UserPositionHistory.trackChange = async 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 await UserPositionHistory.insertAsync(historyEntry);
};
UserPositionHistory.cleanup = async function() {
const users = await Meteor.users.find({}, { fields: { _id: 1 } }).fetchAsync();
for (const user of users) {
const boards = await Boards.find({ 'members.userId': user._id }, { fields: { _id: 1 } }).fetchAsync();
for (const board of boards) {
const history = await UserPositionHistory.find(
{ userId: user._id, boardId: board._id, isCheckpoint: { $ne: true } },
{ sort: { createdAt: -1 }, limit: 1000 },
).fetchAsync();
if (history.length >= 1000) {
const oldestToKeep = history[999].createdAt;
await UserPositionHistory.removeAsync({
userId: user._id,
boardId: board._id,
createdAt: { $lt: oldestToKeep },
isCheckpoint: { $ne: true },
});
}
}
}
};
if (Meteor.settings.public?.enableHistoryCleanup !== false) {
Meteor.setInterval(() => {
try {
UserPositionHistory.cleanup().catch(error => {
console.error('Error during history cleanup:', error);
});
} catch (e) {
console.error('Error during history cleanup:', e);
}
}, 24 * 60 * 60 * 1000);
}
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');
}
await requireBoardVisible(this.userId, boardId);
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 = await UserPositionHistory.findOneAsync({ _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');
}
await requireBoardVisible(this.userId, boardId);
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');
}
await requireBoardVisible(this.userId, boardId);
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 = await UserPositionHistory.findOneAsync({
_id: checkpointId,
userId: this.userId,
isCheckpoint: true,
});
if (!checkpoint) {
throw new Meteor.Error('not-found', 'Checkpoint not found');
}
// Restoring re-applies position changes to the board's entities, so the
// caller must still be allowed to see the board (they may have been removed
// since the checkpoint was created).
await requireBoardVisible(this.userId, checkpoint.boardId);
const changesToUndo = await UserPositionHistory.find(
{
userId: this.userId,
boardId: checkpoint.boardId,
createdAt: { $gt: checkpoint.createdAt },
isCheckpoint: { $ne: true },
},
{ sort: { createdAt: -1 } },
).fetchAsync();
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 };
},
});