Files
wekan/models/import.js
T
Lauri Ojansivu c0a1a27847 Import: always create virtual users, map later from the sidebar; bound imports
Board import no longer asks for member mapping up front. Every imported member is
brought in as a virtual (placeholder) user carrying its avatar, username and full
name (the existing createPlaceholderUsers path), added to the board inactive with
no permissions. The client import flow is now a single step that imports
immediately with an empty mapping -- no map-members screen, nothing to get stuck
on.

Mapping a virtual member to an existing user is a deliberate, later action done by
a board admin from the board sidebar member-avatar popup ("Map to existing user"),
and CANNOT be used to gain privileges: the new board-scoped method
mapImportedBoardMember only maps a virtual member onto an existing ACTIVE, REAL
member of the SAME board and never changes that member's role (so no new
membership is created and nothing is escalated); authorization and this guarantee
live in the pure planBoardMemberMapping helper. The mapping reassigns the virtual
member's cards/comments/activities ON THAT BOARD to the target and removes the
placeholder from the board.

Automatic hang mitigation so an import can never spin forever: a client watchdog
surfaces an import-timeout and clears the spinner if importBoard does not answer in
WEKAN_IMPORT_TIMEOUT_MS (default 2 min), and the server bounds creator.create with
the same deadline via a new generic withDeadline() wrapper.

Covered by tests/boardMemberMapPlan.test.cjs (no-escalation + auth matrix),
tests/withDeadline.test.cjs (deadline wrapper) and tests/importVirtualMembers.test.cjs
(wiring).

Thanks to xet7 !
2026-07-21 19:43:48 +03:00

164 lines
6.3 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { TrelloCreator } from './trelloCreator';
import { WekanCreator } from './wekanCreator';
import { CsvCreator } from './csvCreator';
import { JiraCreator } from './jiraCreator';
import { KanboardCreator } from './kanboardCreator';
import { EXTERNAL_PARSERS } from './lib/externalParsers';
import { Exporter } from './exporter';
import { getMembersToMap } from './wekanmapper';
import { assertImportEnabled } from './lib/importExportSecurity';
import { withDeadline } from './lib/withDeadline';
// Hard deadline for a single board import, so a stalled/hung import can never leave the
// client's spinner running forever — the method returns a timeout error instead.
// Tunable via WEKAN_IMPORT_TIMEOUT_MS (0/invalid disables the deadline).
function importDeadlineMs() {
const ms = parseInt(process.env.WEKAN_IMPORT_TIMEOUT_MS, 10);
return Number.isFinite(ms) ? ms : 120000;
}
// Parse an uploaded .xlsx (base64) into the row-array shape the CsvCreator
// consumes (board[0] is the header row). Excel import reuses the CSV creator.
async function parseXlsxToRows(excelBase64) {
// eslint-disable-next-line global-require
const ExcelJS = require('@wekanteam/exceljs');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(Buffer.from(excelBase64, 'base64'));
const worksheet = workbook.worksheets[0];
const rows = [];
if (worksheet) {
worksheet.eachRow(row => {
// row.values is 1-indexed (index 0 is empty); normalize to strings.
rows.push(row.values.slice(1).map(v => (v == null ? '' : String(v))));
});
}
return rows;
}
Meteor.methods({
async importBoard(board, data, importSource, currentBoard) {
// All check() calls must run BEFORE the first `await`: Meteor's
// audit-argument-checks tracks checked arguments on the current async context,
// and awaiting first makes later check()s (e.g. `board` in the switch below) not
// count — throwing "Did not check() all arguments". So check `board` up front
// here (the per-source switch still does its more specific check).
check(board, Match.OneOf(Object, Array));
check(data, Object);
check(importSource, String);
check(currentBoard, Match.Maybe(String));
// Admin Panel / Features / Security: master switch to disable all import.
await assertImportEnabled();
let creator;
let importedBoard = board;
switch (importSource) {
case 'trello':
check(board, Object);
creator = new TrelloCreator(data);
break;
case 'wekan':
check(board, Object);
creator = new WekanCreator(data);
break;
case 'csv':
check(board, Array);
creator = new CsvCreator(data);
break;
case 'jira':
check(board, Object);
creator = new JiraCreator(data);
break;
case 'kanboard':
check(board, Object);
creator = new KanboardCreator(data);
break;
case 'excel':
// board = { excelBase64 }; parse it into rows and reuse the CSV creator.
check(board, Object);
importedBoard = await parseXlsxToRows(board.excelBase64);
creator = new CsvCreator(data);
break;
default:
// NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo:
// normalize the platform's JSON to the common Kanboard shape and reuse
// the Kanboard creator.
if (EXTERNAL_PARSERS[importSource]) {
check(board, Match.OneOf(Object, Array));
importedBoard = EXTERNAL_PARSERS[importSource](board);
creator = new KanboardCreator(data);
}
break;
}
if (!creator) {
throw new Meteor.Error('invalid-import-source', `Unknown import source: ${importSource}`);
}
// 1. check all parameters are ok from a syntax point of view
//creator.check(board);
// 2. check parameters are ok from a business point of view (exist &
// authorized) nothing to check, everyone can import boards in their account
// 3. create all elements, bounded by a hard deadline on the server so a hung
// import (e.g. a database operation that never returns) surfaces a timeout error
// to the client instead of spinning forever. The client also runs its own watchdog.
if (Meteor.isServer) {
return await withDeadline(
creator.create(importedBoard, currentBoard),
importDeadlineMs(),
() => new Meteor.Error('import-timeout', 'Import took too long and was aborted'),
);
}
return await creator.create(importedBoard, currentBoard);
},
});
Meteor.methods({
async cloneBoard(sourceBoardId, currentBoardId) {
check(sourceBoardId, String);
check(currentBoardId, Match.Maybe(String));
// Cloning reads a board (like export) and creates a new one (like import), so
// it is gated by the disable-all-import master switch (and, via Exporter.build,
// the disable-all-export switch).
await assertImportEnabled();
// Authorization: a caller may only clone (which reads the entire board)
// a source board they are allowed to see. Without this check any
// authenticated user could clone an arbitrary private board by ID.
// We reuse the same guard the REST export route uses (canExport ->
// board.isVisibleBy), since cloning exposes the same data as an export.
if (!this.userId) {
throw new Meteor.Error('error-notAuthorized');
}
const exporter = new Exporter(sourceBoardId);
const user = await ReactiveCache.getUser(this.userId);
if (!user || !(await exporter.canExport(user))) {
throw new Meteor.Error('error-notAuthorized');
}
const data = await exporter.build();
const additionalData = {};
//get the members to map
const membersMapping = getMembersToMap(data);
//now mirror the mapping done in finishImport in client/components/import/import.js:
if (membersMapping) {
const mappingById = {};
membersMapping.forEach(member => {
if (member.wekanId) {
mappingById[member.id] = member.wekanId;
}
});
additionalData.membersMapping = mappingById;
}
const creator = new WekanCreator(additionalData);
//data.title = `${data.title } - ${ TAPi18n.__('copy-tag')}`;
data.title = `${data.title}`;
return await creator.create(data, currentBoardId);
},
});