mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 02:52:33 -05:00
Share validated export options between public and private CSV routes. Add handler regressions and live export-menu coverage for comma, semicolon and tab delimiters, default columns and private-board access.
682 lines
28 KiB
JavaScript
682 lines
28 KiB
JavaScript
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import { Exporter } from './exporter';
|
|
import { EXTERNAL_EXPORT_FORMATS } from './lib/externalExporters';
|
|
import {
|
|
BOARD_EXPORT_FIELD_KEYS,
|
|
parseExportFields,
|
|
parseExportScope,
|
|
} from './lib/exportFields';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import ImpersonatedUsers from '/models/impersonatedUsers';
|
|
|
|
if (Meteor.isServer) {
|
|
const { WebApp } = require('meteor/webapp');
|
|
const { sendJsonResult, safeRoute } = require('/server/apiMiddleware');
|
|
const { Authentication } = require('/server/authentication');
|
|
|
|
// Record an export authorization denial to the Admin Panel security log
|
|
// (best-effort, never breaks the response). See docs/Security/Remediation/WeKan.md.
|
|
// Record an export authorization denial to the Admin Panel security log
|
|
// (best-effort, never breaks the response). Only when the caller cannot see
|
|
// the board at all: an assigned-only member reaches the export menus and is
|
|
// refused in ordinary use, and recording that (high, blocked) disabled their
|
|
// account. See docs/Security/Remediation/WeKan.md.
|
|
function logExportDenied(user, boardId) {
|
|
(async () => {
|
|
try {
|
|
const Boards = require('/models/boards').default;
|
|
const board = await Boards.findOneAsync({ _id: boardId });
|
|
if (board && user && board.isVisibleBy(user)) return;
|
|
require('/server/lib/securityLog').record({
|
|
key: 'authz.export', action: 'blocked', source: 'export',
|
|
detail: 'export denied (no board visibility)',
|
|
});
|
|
} catch (e) { /* logging must never break the response */ }
|
|
})();
|
|
}
|
|
|
|
// Stream a full board export straight to the response (a card/attachment at a
|
|
// time) instead of buffering the whole board object — see Exporter.buildStream.
|
|
async function streamJsonBoardExport(res, exporter) {
|
|
res.statusCode = 200;
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Content-Type', 'application/json');
|
|
try {
|
|
await exporter.buildStream(res);
|
|
res.end();
|
|
} catch (error) {
|
|
console.error('board export stream failed:', error);
|
|
// If we have not flushed any body yet the headers are still mutable, so we
|
|
// can return a clean JSON error; otherwise the partial body is already on
|
|
// the wire. Abort the transfer so clients cannot mistake it for a
|
|
// successfully downloaded backup.
|
|
if (!res.headersSent) sendJsonResult(res, { code: 500, data: { error: 'Export failed' } });
|
|
else res.destroy();
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// so that we could use something like
|
|
// `ApiRoutes.path('boards/export', boardId)``
|
|
// on the client instead of copy/pasting the route path manually between the
|
|
// client and the server.
|
|
/**
|
|
* @operation exportJson
|
|
* @tag Boards
|
|
*
|
|
* @summary This route is used to export the board to a json file format.
|
|
*
|
|
* @description If user is already logged-in, pass loginToken as param
|
|
* "authToken": '/api/boards/:boardId/export?authToken=:token'
|
|
*
|
|
* See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/
|
|
* for detailed explanations
|
|
*
|
|
* @param {string} boardId the ID of the board we are exporting
|
|
* @param {string} authToken the loginToken
|
|
*/
|
|
WebApp.handlers.get('/api/boards/:boardId/export', safeRoute(async function (req, res) {
|
|
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
|
|
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
|
|
const boardId = req.params.boardId;
|
|
let user = null;
|
|
let impersonateDone = false;
|
|
let adminId = null;
|
|
|
|
// First check if board exists and is public to avoid unnecessary authentication
|
|
const board = await ReactiveCache.getBoard(boardId);
|
|
if (!board) {
|
|
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
|
|
return;
|
|
}
|
|
|
|
// #5870: ?attachments=false exports the board without the base64 attachment
|
|
// file data, so very large boards can be exported without overflowing the
|
|
// JSON serializer. Default (omitted/any other value) keeps the full export.
|
|
// #1173: the same two the PDF, Excel and .zip exports read - WHICH cards
|
|
// (a swimlane, a list, a card, a checklist, or the whole board) and WHICH
|
|
// PARTS of them, from the one selection popup. Parsed by the shared reader
|
|
// so a menu's `listId` means the same thing at every export route.
|
|
const exportOptions = {
|
|
excludeAttachments: req.query.attachments === 'false',
|
|
scope: parseExportScope(req.query),
|
|
fields: parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS),
|
|
};
|
|
|
|
// If board is public, skip expensive authentication operations
|
|
if (board.isPublic()) {
|
|
// Public boards don't require authentication - skip hash operations
|
|
const exporter = new Exporter(boardId, undefined, exportOptions);
|
|
await streamJsonBoardExport(res, exporter);
|
|
return;
|
|
}
|
|
|
|
// Only perform expensive authentication for private boards
|
|
const loginToken = req.query.authToken;
|
|
if (loginToken) {
|
|
// Validate token length to prevent resource abuse
|
|
if (loginToken.length > 10000) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
|
|
}
|
|
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
|
|
return;
|
|
}
|
|
|
|
user = await require('/server/lib/activeUser').activeUserByToken(loginToken, 'export', req);
|
|
if (!user) {
|
|
// GHSA-3gcg-g6rf-w2rx: an authToken that matches no login token makes the
|
|
// lookup answer `undefined`, and `user._id` on that threw a TypeError out of
|
|
// an async route handler - an unhandled rejection, which this app turns into
|
|
// a process crash (see the note in server/ldapGroupSync.js). One crafted GET
|
|
// took the whole server down. The same guard already existed in
|
|
// models/exportPDF.js and models/exportExcelCard.js; these handlers were
|
|
// missed.
|
|
sendJsonResult(res, { code: 401, data: { error: 'Invalid token' } });
|
|
return;
|
|
}
|
|
adminId = user._id.toString();
|
|
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId: adminId });
|
|
} else if (!Meteor.settings.public.sandstorm) {
|
|
try {
|
|
// Any logged-in user may request an export; board-level access is
|
|
// enforced below by exporter.canExport() (board.isVisibleBy).
|
|
Authentication.checkLoggedIn(req.userId);
|
|
} catch (error) {
|
|
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
|
|
return;
|
|
}
|
|
user = await ReactiveCache.getUser({ _id: req.userId });
|
|
}
|
|
|
|
// Anonymized user placeholders ("user1"/"käyttäjä1") follow the exporting
|
|
// user's language.
|
|
exportOptions.userLanguage = (user && user.profile && user.profile.language) || 'en';
|
|
const exporter = new Exporter(boardId, undefined, exportOptions);
|
|
if (await exporter.canExport(user)) {
|
|
if (impersonateDone && adminId) {
|
|
await ImpersonatedUsers.insertAsync({
|
|
adminId: adminId,
|
|
boardId: boardId,
|
|
reason: 'exportJSON',
|
|
});
|
|
}
|
|
|
|
await streamJsonBoardExport(res, exporter);
|
|
} 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.
|
|
logExportDenied(user, boardId);
|
|
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @operation exportCalendarIcs
|
|
* @tag Boards
|
|
*
|
|
* @summary Subscribable iCalendar (.ics) feed of a board's card dates.
|
|
*
|
|
* @description Answers GitHub issue #2836 ("CalDAV or iCal Support") - #808
|
|
* added the in-app Calendar VIEW, this is the separate EXPORT/subscribe
|
|
* feed the issue actually asked for. One VEVENT per Received / Start-End
|
|
* span / Due date a card has, the same four dates the Calendar view (#808)
|
|
* draws (see models/lib/calendarFilter.js and models/lib/icalExport.js).
|
|
* This is one-way and read-only: paste the URL into a calendar app's
|
|
* "subscribe to URL" feature. Full two-way CalDAV sync is a much larger,
|
|
* separate, stateful sync protocol and is out of scope.
|
|
*
|
|
* @param {string} boardId the ID of the board whose card dates to export
|
|
* @param {string} authToken the loginToken (omit for a public board)
|
|
*/
|
|
WebApp.handlers.get('/api/boards/:boardId/calendar.ics', safeRoute(async function (req, res) {
|
|
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
|
|
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
|
|
const boardId = req.params.boardId;
|
|
let user = null;
|
|
|
|
const board = await ReactiveCache.getBoard(boardId);
|
|
if (!board) {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end('Board not found');
|
|
return;
|
|
}
|
|
|
|
if (!board.isPublic()) {
|
|
const loginToken = req.query.authToken;
|
|
if (loginToken) {
|
|
if (loginToken.length > 10000) {
|
|
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end('Invalid token');
|
|
return;
|
|
}
|
|
user = await require('/server/lib/activeUser').activeUserByToken(loginToken, 'export', req);
|
|
if (!user) {
|
|
// GHSA-3gcg-g6rf-w2rx - see the note above: an unknown token answers
|
|
// `undefined`, and dereferencing it crashed the server.
|
|
res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end('Invalid token');
|
|
return;
|
|
}
|
|
} else if (!Meteor.settings.public.sandstorm) {
|
|
try {
|
|
Authentication.checkLoggedIn(req.userId);
|
|
} catch (error) {
|
|
res.writeHead(error.statusCode || 403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end((error && error.reason) || 'Forbidden');
|
|
return;
|
|
}
|
|
user = await ReactiveCache.getUser({ _id: req.userId });
|
|
}
|
|
}
|
|
|
|
const exportOptions = { scope: parseExportScope(req.query) };
|
|
const exporter = new Exporter(boardId, undefined, exportOptions);
|
|
if (!(await exporter.canExport(user))) {
|
|
logExportDenied(user, boardId);
|
|
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end('Forbidden');
|
|
return;
|
|
}
|
|
|
|
const { cardsToIcs } = require('/models/lib/icalExport');
|
|
const cardSelector = await exporter._scopedCardSelector(boardId);
|
|
cardSelector.archived = false;
|
|
const cards = await ReactiveCache.getCards(cardSelector);
|
|
// Admin Panel / Anonymize export users: @mentions in what the feed carries
|
|
// (CrossBoardExportBleed - only the JSON export anonymized).
|
|
await require('/models/lib/importExportSecurity').anonymizeExportIdentities({ boardId, cards });
|
|
const cardShapes = cards
|
|
.filter(card => card.startAt || card.dueAt || card.endAt || card.receivedAt)
|
|
.map(card => ({
|
|
_id: card._id,
|
|
title: card.title,
|
|
description: card.description,
|
|
startAt: card.startAt,
|
|
dueAt: card.dueAt,
|
|
endAt: card.endAt,
|
|
receivedAt: card.receivedAt,
|
|
url: typeof card.absoluteUrl === 'function' ? card.absoluteUrl(board) : undefined,
|
|
}));
|
|
|
|
const icsText = cardsToIcs(cardShapes, { calendarName: `WeKan - ${board.title}` });
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/calendar; charset=utf-8',
|
|
'Content-Disposition': `inline; filename="${board.slug || board._id}.ics"`,
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(icsText);
|
|
}));
|
|
|
|
/**
|
|
* @operation exportZip
|
|
* @tag Boards
|
|
* @summary Export as a .zip - the JSON document, and the attachments as files.
|
|
* @description The same export the JSON route produces, with the attachments
|
|
* beside it as the files they are instead of base64 inside the document, so a
|
|
* board whose attachments are too large for one JSON string still exports.
|
|
* Takes the same `fields`, `swimlaneId`, `listId`, `cardId` and `checklistId`
|
|
* parameters as every other export.
|
|
* @param {string} boardId the ID of the board we are exporting
|
|
* @param {string} authToken the loginToken
|
|
*/
|
|
WebApp.handlers.get('/api/boards/:boardId/exportZip', safeRoute(async function (req, res) {
|
|
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
|
|
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
|
|
const boardId = req.params.boardId;
|
|
let user = null;
|
|
|
|
const board = await ReactiveCache.getBoard(boardId);
|
|
if (!board) {
|
|
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
|
|
return;
|
|
}
|
|
|
|
const options = {
|
|
scope: parseExportScope(req.query),
|
|
fields: parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS),
|
|
};
|
|
|
|
if (!board.isPublic()) {
|
|
const loginToken = req.query.authToken;
|
|
if (loginToken) {
|
|
if (loginToken.length > 10000) {
|
|
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
|
|
return;
|
|
}
|
|
user = await require('/server/lib/activeUser').activeUserByToken(loginToken, 'export', req);
|
|
if (!user) {
|
|
sendJsonResult(res, { code: 401, data: { error: 'Invalid token' } });
|
|
return;
|
|
}
|
|
} else if (!Meteor.settings.public.sandstorm) {
|
|
try {
|
|
Authentication.checkLoggedIn(req.userId);
|
|
} catch (error) {
|
|
sendJsonResult(res, {
|
|
code: error.statusCode || 403,
|
|
data: { error: (error && error.reason) || 'Forbidden' },
|
|
});
|
|
return;
|
|
}
|
|
user = await ReactiveCache.getUser({ _id: req.userId });
|
|
}
|
|
}
|
|
|
|
options.userLanguage = (user && user.profile && user.profile.language) || 'en';
|
|
const { ExporterZip } = require('./server/ExporterZip');
|
|
const exporter = new ExporterZip(boardId, options);
|
|
if (await exporter.canExport(user)) {
|
|
const name = String(board.title || 'export-board')
|
|
.replace(/[^a-z0-9._-]+/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
.slice(0, 80) || 'export-board';
|
|
// AWAITED: an un-awaited build(res) is #6591 - a rejection that goes
|
|
// nowhere and a response that is never ended.
|
|
await exporter.build(res, `${name}.zip`);
|
|
} else {
|
|
logExportDenied(user, boardId);
|
|
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
|
|
}
|
|
}));
|
|
|
|
// Kanboard is one of the formats below: GET /api/boards/:boardId/export/kanboard
|
|
// is served by serveExternalExport, with the export selection and the
|
|
// admin-only custom field check every other format has.
|
|
|
|
// Generalized export to other tools: NextCloud Deck, Kanboard, OpenProject,
|
|
// GitHub, GitLab, Gitea, Forgejo, Jira, Asana, Zenkit, Trello, Markdown, Leo. One shared auth handler, one route per
|
|
// format. Every one of these formats is JSON except Markdown and Leo, which are
|
|
// plain text meant to be read/edited directly or opened by the tool they
|
|
// belong to - wrapping them in `{"data": "..."}` would defeat that.
|
|
async function serveExternalExport(req, res, format) {
|
|
const boardId = req.params.boardId;
|
|
const board = await ReactiveCache.getBoard(boardId);
|
|
if (!board) {
|
|
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
|
|
return;
|
|
}
|
|
let user = null;
|
|
const respond = async () => {
|
|
await require('/server/lib/adminOnlyCustomFields').assertFieldExport(boardId, user?._id);
|
|
// The bytes for this format (server/lib/renderExternalExport.js), shared
|
|
// with "Export all boards" (server/lib/exportAllBoards.js).
|
|
const rendered = await require('/server/lib/renderExternalExport').renderExternalExport(boardId, format,
|
|
parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS));
|
|
if (rendered.json !== undefined) {
|
|
sendJsonResult(res, { code: 200, data: rendered.json });
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': rendered.contentType });
|
|
res.end(rendered.body);
|
|
};
|
|
if (board.isPublic()) {
|
|
await respond();
|
|
return;
|
|
}
|
|
const loginToken = req.query.authToken;
|
|
if (loginToken) {
|
|
if (loginToken.length > 10000) {
|
|
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
|
|
return;
|
|
}
|
|
user = await require('/server/lib/activeUser').activeUserByToken(loginToken, 'export', req);
|
|
if (!user) {
|
|
// GHSA-3gcg-g6rf-w2rx: this handler does not dereference the user - it hands
|
|
// it to canExport(), which answers false - so an unknown token got a 403
|
|
// rather than a crash. It is guarded all the same: one rule for every token
|
|
// lookup is what keeps the next handler from being the one that is missed,
|
|
// and "your token is invalid" is the honest answer to an invalid token.
|
|
sendJsonResult(res, { code: 401, data: { error: 'Invalid token' } });
|
|
return;
|
|
}
|
|
} else if (!Meteor.settings.public.sandstorm) {
|
|
try {
|
|
// Any logged-in user may request an export; board-level access is
|
|
// enforced below by exporter.canExport() (board.isVisibleBy).
|
|
Authentication.checkLoggedIn(req.userId);
|
|
} catch (error) {
|
|
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
|
|
return;
|
|
}
|
|
user = await ReactiveCache.getUser({ _id: req.userId });
|
|
}
|
|
const exporter = new Exporter(boardId);
|
|
if (await exporter.canExport(user)) {
|
|
await respond();
|
|
} else {
|
|
logExportDenied(user, boardId);
|
|
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
|
|
}
|
|
}
|
|
|
|
EXTERNAL_EXPORT_FORMATS.forEach(format => {
|
|
/**
|
|
* @operation exportExternal
|
|
* @tag Boards
|
|
* @summary Export the board as a NextCloud Deck / Kanboard / OpenProject /
|
|
* GitHub / GitLab / Gitea / Forgejo / Jira / Asana / Zenkit / Trello style
|
|
* JSON, a Taiga project dump (JSON), or Markdown.
|
|
* @param {string} boardId the ID of the board we are exporting
|
|
* @param {string} authToken the loginToken
|
|
*/
|
|
WebApp.handlers.get(`/api/boards/:boardId/export/${format}`, safeRoute(async function (req, res) {
|
|
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
|
|
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
|
|
await serveExternalExport(req, res, format);
|
|
}));
|
|
});
|
|
|
|
// 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
|
|
// so that we could use something like
|
|
// `ApiRoutes.path('boards/export', boardId)``
|
|
// on the client instead of copy/pasting the route path manually between the
|
|
// client and the server.
|
|
/**
|
|
* @operation exportJson
|
|
* @tag Boards
|
|
*
|
|
* @summary This route is used to export a attachement to a json file format.
|
|
*
|
|
* @description If user is already logged-in, pass loginToken as param
|
|
* "authToken": '/api/boards/:boardId/attachments/:attachmentId/export?authToken=:token'
|
|
*
|
|
*
|
|
* @param {string} boardId the ID of the board we are exporting
|
|
* @param {string} attachmentId the ID of the attachment we are exporting
|
|
* @param {string} authToken the loginToken
|
|
*/
|
|
WebApp.handlers.get(
|
|
'/api/boards/:boardId/attachments/:attachmentId/export',
|
|
safeRoute(async function (req, res) {
|
|
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
|
|
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
|
|
const boardId = req.params.boardId;
|
|
const attachmentId = req.params.attachmentId;
|
|
let user = null;
|
|
let impersonateDone = false;
|
|
let adminId = null;
|
|
|
|
// First check if board exists and is public to avoid unnecessary authentication
|
|
const board = await ReactiveCache.getBoard(boardId);
|
|
if (!board) {
|
|
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
|
|
return;
|
|
}
|
|
|
|
// If board is public, skip expensive authentication operations
|
|
if (board.isPublic()) {
|
|
// Public boards don't require authentication - skip hash operations
|
|
const exporter = new Exporter(boardId, attachmentId);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: await exporter.build(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Only perform expensive authentication for private boards
|
|
const loginToken = req.query.authToken;
|
|
if (loginToken) {
|
|
// Validate token length to prevent resource abuse
|
|
if (loginToken.length > 10000) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
|
|
}
|
|
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
|
|
return;
|
|
}
|
|
|
|
user = await require('/server/lib/activeUser').activeUserByToken(loginToken, 'export', req);
|
|
if (!user) {
|
|
// GHSA-3gcg-g6rf-w2rx: an authToken that matches no login token makes the
|
|
// lookup answer `undefined`, and `user._id` on that threw a TypeError out of
|
|
// an async route handler - an unhandled rejection, which this app turns into
|
|
// a process crash (see the note in server/ldapGroupSync.js). One crafted GET
|
|
// took the whole server down. The same guard already existed in
|
|
// models/exportPDF.js and models/exportExcelCard.js; these handlers were
|
|
// missed.
|
|
sendJsonResult(res, { code: 401, data: { error: 'Invalid token' } });
|
|
return;
|
|
}
|
|
adminId = user._id.toString();
|
|
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId: adminId });
|
|
} else if (!Meteor.settings.public.sandstorm) {
|
|
try {
|
|
// Any logged-in user may request an export; board-level access is
|
|
// enforced below by exporter.canExport() (board.isVisibleBy).
|
|
Authentication.checkLoggedIn(req.userId);
|
|
} catch (error) {
|
|
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
|
|
return;
|
|
}
|
|
user = await ReactiveCache.getUser({ _id: req.userId, isAdmin: true });
|
|
}
|
|
|
|
const exporter = new Exporter(boardId, attachmentId);
|
|
if (await exporter.canExport(user)) {
|
|
if (impersonateDone) {
|
|
await ImpersonatedUsers.insertAsync({
|
|
adminId: adminId,
|
|
boardId: boardId,
|
|
attachmentId: attachmentId,
|
|
reason: 'exportJSONattachment',
|
|
});
|
|
}
|
|
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.
|
|
logExportDenied(user, boardId);
|
|
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
|
|
}
|
|
}),
|
|
);
|
|
|
|
/**
|
|
* @operation exportCSV/TSV
|
|
* @tag Boards
|
|
*
|
|
* @summary This route is used to export the board to a CSV or TSV file format.
|
|
*
|
|
* @description If user is already logged-in, pass loginToken as param
|
|
*
|
|
* See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/
|
|
* for detailed explanations
|
|
*
|
|
* @param {string} boardId the ID of the board we are exporting
|
|
* @param {string} authToken the loginToken
|
|
* @param {string} delimiter delimiter to use while building export. Default is comma ','
|
|
*/
|
|
WebApp.handlers.get('/api/boards/:boardId/export/csv', safeRoute(async function (req, res) {
|
|
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
|
|
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
|
|
const boardId = req.params.boardId;
|
|
let user = null;
|
|
let impersonateDone = false;
|
|
let adminId = null;
|
|
|
|
// First check if board exists and is public to avoid unnecessary authentication
|
|
const board = await ReactiveCache.getBoard(boardId);
|
|
if (!board) {
|
|
res.writeHead(404);
|
|
res.end('Board not found');
|
|
return;
|
|
}
|
|
|
|
// The same column selection applies to public and private boards.
|
|
const exportOptions = {
|
|
fields: parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS),
|
|
};
|
|
|
|
// If board is public, skip expensive authentication operations
|
|
if (board.isPublic()) {
|
|
// Public boards don't require authentication - skip hash operations
|
|
const exporter = new Exporter(boardId, undefined, exportOptions);
|
|
|
|
if( req.query.delimiter == "\t" ) {
|
|
// TSV file
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/tsv',
|
|
});
|
|
}
|
|
else {
|
|
// CSV file (comma or semicolon)
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/csv; charset=utf-8',
|
|
});
|
|
// Adding UTF8 BOM to quick fix MS Excel issue
|
|
// use Uint8Array to prevent from converting bytes to string
|
|
res.write(new Uint8Array([0xEF, 0xBB, 0xBF]));
|
|
}
|
|
await exporter.buildCsvStream(res, req.query.delimiter, 'en');
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Only perform expensive authentication for private boards
|
|
const loginToken = req.query.authToken;
|
|
if (loginToken) {
|
|
// Validate token length to prevent resource abuse
|
|
if (loginToken.length > 10000) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
|
|
}
|
|
res.writeHead(400);
|
|
res.end('Invalid token');
|
|
return;
|
|
}
|
|
|
|
user = await require('/server/lib/activeUser').activeUserByToken(loginToken, 'export', req);
|
|
if (!user) {
|
|
// GHSA-3gcg-g6rf-w2rx - see the note in models/export.js: an unknown token
|
|
// answers `undefined`, and dereferencing it crashed the server.
|
|
res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end('Invalid token');
|
|
return;
|
|
}
|
|
adminId = user._id.toString();
|
|
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId: adminId });
|
|
} else if (!Meteor.settings.public.sandstorm) {
|
|
try {
|
|
// Any logged-in user may request an export; board-level access is
|
|
// enforced below by exporter.canExport() (board.isVisibleBy).
|
|
Authentication.checkLoggedIn(req.userId);
|
|
} catch (error) {
|
|
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
|
|
return;
|
|
}
|
|
user = await ReactiveCache.getUser({
|
|
_id: req.userId,
|
|
isAdmin: true,
|
|
});
|
|
}
|
|
|
|
const exporter = new Exporter(boardId, undefined, exportOptions);
|
|
if (await exporter.canExport(user)) {
|
|
if (impersonateDone) {
|
|
let exportType = 'exportCSV';
|
|
if( req.query.delimiter == "\t" ) {
|
|
exportType = 'exportTSV';
|
|
}
|
|
await ImpersonatedUsers.insertAsync({
|
|
adminId: adminId,
|
|
boardId: boardId,
|
|
reason: exportType,
|
|
});
|
|
}
|
|
|
|
let userLanguage = 'en';
|
|
if (user && user.profile) {
|
|
userLanguage = user.profile.language;
|
|
}
|
|
|
|
if( req.query.delimiter == "\t" ) {
|
|
// TSV file
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/tsv',
|
|
});
|
|
}
|
|
else {
|
|
// CSV file (comma or semicolon)
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/csv; charset=utf-8',
|
|
});
|
|
// Adding UTF8 BOM to quick fix MS Excel issue
|
|
// use Uint8Array to prevent from converting bytes to string
|
|
res.write(new Uint8Array([0xEF, 0xBB, 0xBF]));
|
|
}
|
|
await exporter.buildCsvStream(res, req.query.delimiter, userLanguage);
|
|
res.end();
|
|
} else {
|
|
res.writeHead(403);
|
|
res.end('Permission Error');
|
|
}
|
|
}));
|
|
}
|