Files
wekan/models/lib/importExportSecurity.js
T
Lauri Ojansivu 06fdede782 Admin Panel / Features / Security: import/export privacy controls
Six new optional toggles (all default off) governing how boards and user data
cross the WeKan boundary, enforced server-side so they cannot be bypassed from
the client:

- Disable all import / Disable all export: master switches that turn off every
  import/export feature. importBoard and cloneBoard call assertImportEnabled;
  every Exporter build()/buildStream() call assertExportEnabled. The import and
  export menu options are hidden in the UI via global importDisabled/exportDisabled
  Blaze helpers.
- Disable import avatars / Disable export avatars: never carry avatars into/out of
  WeKan. Import is gated at the single localizeAvatarFromBuffer choke point (covers
  WeKan JSON import, LDAP, OIDC/OAuth2 login sync and board-open localization) plus
  Trello inlineMemberAvatars; export skips the base64 avatar embed + avatarUrl in
  both Exporter paths.
- Anonymize import users / Anonymize export users: replace username/fullname/initials
  with counter placeholders (user1, user2, ...), drop avatars, and rewrite @username
  mentions + requestedBy/assignedBy in card/comment text. The placeholder word "user"
  is translated to the current user language. Export covers both build() and the
  streaming buildStream() (which does an id-only pre-scan so mentions streamed before
  the users array resolve to matching labels); import scrubs the incoming board in
  WekanCreator before any placeholder user or card is created.

New shared server helper models/lib/importExportSecurity.js holds the settings
read, guards, anonymization map builder and text rewriter. Adds settings
disableAllImport, disableAllExport, disableImportAvatars, disableExportAvatars,
anonymizeImportUsers, anonymizeExportUsers, published in the public setting.
2026-07-14 13:53:11 +03:00

147 lines
5.7 KiB
JavaScript

// Server-side helpers for the Admin Panel / Features / Security import/export
// options:
// - disableExportAvatars / disableImportAvatars: never carry avatars (user
// profile pictures) out of / into WeKan.
// - anonymizeExportUsers / anonymizeImportUsers: replace user identity fields
// (username, fullname, initials) with counter placeholders (user1, user2, ...)
// as a board is exported / imported, and rewrite @username mentions inside
// card and comment text so no real identity remains.
//
// The placeholder word ("user") is translatable, so a Finnish user gets
// "käyttäjä1", "käyttäjä2", etc.
// Read the import/export security settings once. Server-side only.
export async function getImportExportSecuritySettings() {
const Settings = require('/models/settings').default;
const s = (await Settings.findOneAsync({})) || {};
return {
disableAllExport: !!s.disableAllExport,
disableAllImport: !!s.disableAllImport,
disableExportAvatars: !!s.disableExportAvatars,
disableImportAvatars: !!s.disableImportAvatars,
anonymizeExportUsers: !!s.anonymizeExportUsers,
anonymizeImportUsers: !!s.anonymizeImportUsers,
};
}
// Throw if the admin has disabled ALL export. Call at every export entry point.
export async function assertExportEnabled() {
const Settings = require('/models/settings').default;
const s = (await Settings.findOneAsync({})) || {};
if (s.disableAllExport) {
const { Meteor } = require('meteor/meteor');
throw new Meteor.Error('export-disabled', 'Export is disabled by the administrator.');
}
}
// Throw if the admin has disabled ALL import. Call at every import entry point.
export async function assertImportEnabled() {
const Settings = require('/models/settings').default;
const s = (await Settings.findOneAsync({})) || {};
if (s.disableAllImport) {
const { Meteor } = require('meteor/meteor');
throw new Meteor.Error('import-disabled', 'Import is disabled by the administrator.');
}
}
// The translated word used as the anonymized username prefix ("user" -> user1;
// "käyttäjä" -> käyttäjä1). Falls back to "user" for any lookup problem.
export function anonymizedUserWord(language) {
try {
const { TAPi18n } = require('/imports/i18n');
const w = TAPi18n.__('anonymized-user', {}, language || 'en');
if (typeof w === 'string' && w.trim()) return w.trim();
} catch (e) {
/* fall through to default */
}
return 'user';
}
// Build a deterministic anonymization map from a list of user docs
// ({ _id, username }). Indices are assigned in _id order, so the same board
// always produces the same labels and export/import stay consistent.
// Returns { byId: Map(userId -> {username, fullname, initials}),
// byUsername: Map(oldUsername -> newUsername) }.
export function buildUserAnonymizationMap(users, userWord) {
const word = userWord || 'user';
const initialChar = (word.charAt(0) || 'u').toUpperCase();
const byId = new Map();
const byUsername = new Map();
(users || [])
.filter(u => u && u._id)
.slice()
.sort((a, b) => {
const x = String(a._id);
const y = String(b._id);
return x < y ? -1 : x > y ? 1 : 0;
})
.forEach((u, idx) => {
const n = idx + 1;
const label = `${word}${n}`;
byId.set(String(u._id), {
username: label,
fullname: label,
initials: `${initialChar}${n}`,
});
if (u.username) byUsername.set(u.username, label);
});
return { byId, byUsername };
}
// Replace one user doc's identity fields in place with its anonymized label.
// Also drops every avatar field: an avatar identifies the person, so a truly
// anonymized user carries none.
export function anonymizeUserDoc(user, map) {
if (!user || !user._id) return user;
const a = map.byId.get(String(user._id));
if (!a) return user;
user.username = a.username;
user.profile = user.profile || {};
user.profile.fullname = a.fullname;
user.profile.initials = a.initials;
delete user.profile.avatarUrl;
delete user.profile.avatarFile;
delete user.profile.avatarFileName;
delete user.profile.avatarFileType;
return user;
}
// Rewrite @username mentions inside free text to the anonymized labels. Only
// known usernames are rewritten; an @ that is not a known member is left as-is.
// The leading (^|[^\w@]) guard keeps email-like "a@b" text from matching.
export function rewriteMentionsInText(text, byUsername) {
if (typeof text !== 'string' || !text || !byUsername || byUsername.size === 0) {
return text;
}
return text.replace(/(^|[^\w@])@([\w.-]+)/g, (full, pre, name) => {
const repl = byUsername.get(name);
return repl ? `${pre}@${repl}` : full;
});
}
// A free-text identity value (e.g. card.requestedBy / assignedBy) is anonymized
// only when it exactly matches a known username.
export function anonymizeIdentityValue(value, byUsername) {
if (typeof value === 'string' && byUsername && byUsername.has(value)) {
return byUsername.get(value);
}
return value;
}
// Apply mention/identity rewriting to a board's card + comment text arrays in
// place, using an already-built byUsername map. Shared by export (build()) and
// import so both scrub the same free-text fields.
export function anonymizeBoardTextInPlace(board, byUsername) {
if (!board) return;
(board.cards || []).forEach(card => {
if (!card) return;
card.title = rewriteMentionsInText(card.title, byUsername);
card.description = rewriteMentionsInText(card.description, byUsername);
card.requestedBy = anonymizeIdentityValue(card.requestedBy, byUsername);
card.assignedBy = anonymizeIdentityValue(card.assignedBy, byUsername);
});
(board.comments || []).forEach(comment => {
if (comment) comment.text = rewriteMentionsInText(comment.text, byUsername);
});
}