mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
Decided on 2026-10-14. On the import page each of the six asks for what its service needs - a self-hosted Planka its address, username and password; the others an access token (Basecamp also its account id) - lists what that account can import, and fetches the chosen board, list or project. The credentials go with each call and are used for that call's requests only; they are never stored or logged, and the page clears secret fields once a fetch has used them. The fetched JSON fills the import text box and the import goes on as for a pasted export: the service's parser (models/lib/<key>ApiFormat.js) into the generalized importer, with the members step and the loss report. The same JSON can be pasted, or posted to POST /api/boards/import/<key>. - server/apiImport.js: apiImportList and apiImportFetch, signed in only, off with the Admin Panel's import switch, not in Sandstorm, rate limited; credentials checked against the service's own fields (models/lib/apiImportServices.js). - server/lib/apiImportRequest.js: every request through the SSRF guard - pinned address, no redirects, bounded in time and size - so a Planka address cannot point the server at its own network; an administrator can allow such a host with API_IMPORT_ALLOWED_PRIVATE_HOSTS. An error names only the origin and the status. - models/lib/apiImport/<key>.js: each service's client, plain Node with the request injected, paging bounded and the token only in a header. - Each has a docs page under docs/Features/ImportExport/, listed in the coverage index and External-Tools.md. Tests: tests/<key>ApiImport.test.cjs (client with a fake request, every parsed field, wrong shapes), tests/apiImport.test.cjs (credentials, the gate, the wiring, the shapes refused), api-import.e2e.js (the form for every service, fetched JSON imported through the page, refusals incl. a private address). Live tests, read only, run from build.sh / build.bat -> Tests -> Live tests with your own accounts (releases/live-tests/README.md); they were not run here. New strings translated in all locales. Also: the release risk baseline allows the services' API addresses, and the guards that count import creators, list the import sources and pin the platform fetch() calls know the new ones. Thanks to xet7 !
80 lines
4.0 KiB
JavaScript
80 lines
4.0 KiB
JavaScript
'use strict';
|
|
|
|
// Import from a service's API (decided 2026-10-14): Planka, Microsoft To Do,
|
|
// KanbanFlow, Basecamp, Google Tasks and Google Keep. The member types what
|
|
// the service needs; server/apiImport.js lists what can be imported and
|
|
// fetches the chosen one with those credentials - used for that request and
|
|
// never stored - and the import page then imports the fetched JSON like a
|
|
// pasted export, through the service's parser (models/lib/<key>ApiFormat.js)
|
|
// and the generalized importer, members step and all.
|
|
//
|
|
// Each service's client is models/lib/apiImport/<key>.js, plain Node:
|
|
// list(credentials, request) -> [{ id, name }] what can be imported
|
|
// fetch(credentials, id, request) -> the JSON its parser reads
|
|
// `request(url, { method, headers, body })` resolves to the parsed JSON body
|
|
// and rejects with an Error carrying `.status` on a non-2xx answer; the
|
|
// server passes one that goes through the SSRF guard
|
|
// (server/lib/apiImportRequest.js), tests a fake, and the live tests
|
|
// (releases/live-tests/<key>-import.mjs) plain fetch.
|
|
//
|
|
// fields: what the import page asks for, in order. `secret` fields are
|
|
// password inputs, cleared once sent. `url` is the service's address for a
|
|
// self-hosted one; a hosted service's address is fixed in its client.
|
|
|
|
const API_IMPORT_SERVICES = [
|
|
{ key: 'planka', name: 'Planka', fields: [
|
|
{ name: 'url', label: 'api-import-url', required: true },
|
|
{ name: 'username', label: 'api-import-username', required: true },
|
|
{ name: 'password', label: 'api-import-password', secret: true, required: true },
|
|
] },
|
|
{ key: 'mstodo', name: 'Microsoft To Do', fields: [
|
|
{ name: 'token', label: 'api-import-token', secret: true, required: true },
|
|
] },
|
|
{ key: 'kanbanflow', name: 'KanbanFlow', fields: [
|
|
{ name: 'token', label: 'api-import-token', secret: true, required: true },
|
|
] },
|
|
{ key: 'basecamp', name: 'Basecamp', fields: [
|
|
{ name: 'account', label: 'api-import-account', required: true },
|
|
{ name: 'token', label: 'api-import-token', secret: true, required: true },
|
|
] },
|
|
{ key: 'googletasks', name: 'Google Tasks', fields: [
|
|
{ name: 'token', label: 'api-import-token', secret: true, required: true },
|
|
] },
|
|
{ key: 'googlekeep', name: 'Google Keep', fields: [
|
|
{ name: 'token', label: 'api-import-token', secret: true, required: true },
|
|
] },
|
|
];
|
|
|
|
const MAX_FIELD_LENGTH = 4096;
|
|
const apiImportService = key => API_IMPORT_SERVICES.find(service => service.key === key) || null;
|
|
|
|
// The credentials a call may carry: the service's own fields, strings, each
|
|
// required one present. Throws on anything else, so a method never forwards
|
|
// an unexpected value to a client.
|
|
function cleanApiImportCredentials(key, input) {
|
|
const service = apiImportService(key);
|
|
if (!service) throw new Error('Unknown import service');
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Credentials are required');
|
|
const allowed = new Set(service.fields.map(field => field.name));
|
|
for (const name of Object.keys(input)) if (!allowed.has(name)) throw new Error(`Unexpected credential field ${name}`);
|
|
const clean = {};
|
|
for (const field of service.fields) {
|
|
const value = input[field.name];
|
|
if (value === undefined || value === null || value === '') {
|
|
if (field.required) throw new Error(`${field.name} is required`);
|
|
continue;
|
|
}
|
|
if (typeof value !== 'string' || value.length > MAX_FIELD_LENGTH) throw new Error(`${field.name} is not valid`);
|
|
clean[field.name] = field.secret ? value : value.trim();
|
|
}
|
|
if (clean.url !== undefined) {
|
|
let parsed;
|
|
try { parsed = new URL(clean.url); } catch (error) { throw new Error('url is not valid'); }
|
|
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new Error('url is not valid');
|
|
clean.url = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
module.exports = { API_IMPORT_SERVICES, MAX_FIELD_LENGTH, apiImportService, cleanApiImportCredentials };
|