Files
wekan/models/lib/importSourceShape.js
Lauri Ojansivu 46e30f01e4 Import: Planka, Microsoft To Do, KanbanFlow, Basecamp, Google Tasks and Google Keep from their APIs.
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 !
2026-10-14 22:57:15 +03:00

44 lines
3.5 KiB
JavaScript

// Shape validation before creating a board. Unknown/empty objects must not
// report a successful import which silently contains no source records.
export function validateImportSourceShape(source, value) {
const arrayAt = (object, key) => object && Array.isArray(object[key]);
let valid = false;
switch (source) {
case 'wekan': case 'trello': valid = arrayAt(value, 'cards') && arrayAt(value, 'lists'); break;
case 'kanboard': valid = arrayAt(value, 'tasks'); break;
case 'jira': valid = arrayAt(value, 'issues'); break;
case 'github': case 'gitlab': case 'gitea': case 'forgejo':
valid = Array.isArray(value) || arrayAt(value, 'issues'); break;
case 'deck': valid = arrayAt(value, 'stacks') || arrayAt(value?.board, 'stacks'); break;
case 'openproject': valid = Array.isArray(value) || arrayAt(value, 'elements') || arrayAt(value?._embedded, 'elements'); break;
case 'asana': valid = Array.isArray(value) || arrayAt(value, 'data'); break;
case 'zenkit': valid = Array.isArray(value) || arrayAt(value, 'items')
|| (arrayAt(value, 'elements') && (arrayAt(value, 'entries') || arrayAt(value, 'listEntries'))); break;
case 'tasksorg': valid = arrayAt(value?.data, 'tasks'); break;
case 'csv': valid = Array.isArray(value) && value.length > 0 && value.every(Array.isArray); break;
case 'taiga': valid = arrayAt(value, 'user_stories'); break;
case 'kanri': valid = arrayAt(value, 'columns') || arrayAt(value, 'boards'); break;
// What server/apiImport.js fetches (models/lib/apiImport/<key>.js).
case 'planka': valid = Boolean(value?.planka?.item) && typeof value.planka.included === 'object'; break;
case 'mstodo': case 'googletasks': valid = arrayAt(value, 'lists'); break;
case 'kanbanflow': valid = Boolean(value?.board) && arrayAt(value, 'tasks'); break;
case 'basecamp': valid = Boolean(value?.project) && (arrayAt(value, 'cardTables') || arrayAt(value, 'todolists')); break;
case 'googlekeep': valid = arrayAt(value, 'notes'); break;
// Vikunja: the export .zip as { zipBase64 }, or the text of its data.json.
// Notion: the Markdown & CSV export .zip as { zipBase64 }, or one database CSV.
case 'notion':
case 'vikunja': valid = (typeof value?.zipBase64 === 'string' && value.zipBase64.length > 0)
|| (typeof value === 'string' && value.trim().length > 0); break;
// Plane: the export .zip as { zipBase64 }, a workbook as { xlsxBase64 },
// or the text of a JSON or CSV export file.
case 'plane': valid = (typeof value?.zipBase64 === 'string' && value.zipBase64.length > 0)
|| (typeof value?.xlsxBase64 === 'string' && value.xlsxBase64.length > 0)
|| (typeof value === 'string' && value.trim().length > 0); break;
case 'excel': case 'planner': case 'monday': case 'wrike': case 'teamwork': case 'businessmap': valid = typeof value?.excelBase64 === 'string' && value.excelBase64.length > 0; break;
case 'markdown': case 'todotxt': case 'taskwarrior': case 'focalboard': case 'todoist': case 'meistertask': case 'obsidian': case 'linear': case 'ticktick': case 'clickup': case 'nullboard': case 'pivotal': case 'redmine': case 'superproductivity': case 'quire': case 'orgmode': valid = typeof value === 'string' && value.trim().length > 0; break;
case 'leo': valid = typeof value === 'string' && /<leo_file[\s>]/.test(value); break;
case 'opml': valid = typeof value === 'string' && /<opml[\s>]/.test(value); break;
}
if (!valid) throw new Error(`Invalid ${source} import document shape`);
}