Files
Lauri Ojansivu 582a22c291 Fix GitHub code scanning code-quality findings.
Real faults: a card's original-position description formatted a promise
because it did not await the history; the attachment swipe distances and
two drag-scroll locals were undeclared globals; a file that cannot be read
during a GridFS upload left the upload open; api.py called exit() instead of
sys.exit(); a rules test redefined a property and a security-log test kept
an unreachable branch. Also 31 missing semicolons, unused imports in the
card details, exporters and attachment API, unused Python imports, and
comments on empty except clauses. The list-sync scanner test cut its source
at the last Meteor.startup, which the new index startup of #6855 had moved.

Thanks to GitHub CodeQL and xet7 !
2026-11-02 14:15:02 +02:00

682 lines
25 KiB
JavaScript

export default Popup => {
const saveAs = function(blob, filename) {
const dl = document.createElement('a');
dl.href = window.URL.createObjectURL(blob);
dl.onclick = event => document.body.removeChild(event.target);
dl.style.display = 'none';
dl.target = '_blank';
dl.download = filename;
document.body.appendChild(dl);
dl.click();
};
const asyncForEach = async function(array, callback) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
};
// The board is rendered with infinite scroll — each list only keeps ~10 cards
// in the DOM (client/components/lists/listBody.js). A naive DOM-clone export
// would therefore drop every card past the fold. Before cloning we lift every
// list's `cardlimit` so the whole board renders, then wait for Blaze to flush.
const expandAllCardsForExport = async () => {
let changed = false;
Array.from(document.querySelectorAll('.list-body')).forEach(el => {
try {
let view = Blaze.getView(el);
while (view) {
if (view.template && view.template.viewName === 'Template.listBody' && view.templateInstance) {
const inst = view.templateInstance();
if (inst && inst.cardlimit && inst.cardlimit.get() < 1e6) {
inst.cardlimit.set(1e6); // render all cards in this list
changed = true;
}
break;
}
view = view.parentView;
}
} catch (e) { /* best effort per list */ }
});
if (changed) {
// Let the reactive re-render settle before we snapshot the DOM.
await new Promise(resolve => Tracker.afterFlush(() => setTimeout(resolve, 800)));
}
};
const createZipEntries = () => {
const entries = [];
return { entries, file(name, value) { entries.push({ name, value }); } };
};
// Write one entry at a time with the same small ZIP implementation used by
// document conversion. Only old browsers without the File System Access API
// retain the completed archive in memory for a Blob download.
const saveZipStreaming = async (zip, filename) => {
const { Zip, ZipDeflate, strToU8 } = await import('fflate');
let writable = null;
if (typeof window.showSaveFilePicker === 'function') {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'Zip archive', accept: { 'application/zip': ['.zip'] } }],
});
writable = await handle.createWritable();
} catch (e) {
if (e && e.name === 'AbortError') return;
}
}
const chunks = [];
let writeChain = Promise.resolve();
let finish;
let fail;
const completed = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
const archive = new Zip((error, data, final) => {
if (error) { fail(error); return; }
if (writable) writeChain = writeChain.then(() => writable.write(data));
else chunks.push(data);
if (final) writeChain.then(finish, fail);
});
try {
for (const entry of zip.entries) {
const file = new ZipDeflate(entry.name, { level: 6 });
archive.add(file);
let bytes;
if (entry.value instanceof Blob) bytes = new Uint8Array(await entry.value.arrayBuffer());
else if (entry.value instanceof Uint8Array) bytes = entry.value;
else bytes = strToU8(String(entry.value));
file.push(bytes, true);
}
archive.end();
await completed;
if (writable) await writable.close();
else saveAs(new Blob(chunks, { type: 'application/zip' }), filename);
} catch (error) {
archive.terminate();
if (writable) await writable.abort(error).catch(() => {});
throw error;
}
};
const getPageHtmlString = (clonedElement = null) => {
const element = clonedElement || window.document.querySelector('html');
return `<!doctype html>${element.outerHTML}`;
};
const removeAnchors = htmlString => {
const replaceOpenAnchor = htmlString.replace(
new RegExp('<a ', 'gim'),
'<span ',
);
return replaceOpenAnchor.replace(new RegExp('</a', 'gim'), '</span');
};
const ensureSidebarRemoved = (element = null) => {
const target = element || document;
const sidebar = target.querySelector('.board-sidebar.sidebar');
if (sidebar) {
sidebar.remove();
}
};
const cleanBoardHtmlClone = (clonedElement) => {
// Work on cloned element only, not live DOM
Array.from(clonedElement.querySelectorAll('script')).forEach(elem =>
elem.remove(),
);
Array.from(
clonedElement.querySelectorAll('link:not([rel="stylesheet"])'),
).forEach(elem => elem.remove());
const headerQuickAccess = clonedElement.querySelector('#header-quick-access');
if (headerQuickAccess) headerQuickAccess.remove();
// .board-header-btns-group is the wrapper that keeps the board bar's button
// groups on one flex line; remove it too so the export is not left with an
// empty div where the buttons were.
Array.from(
clonedElement.querySelectorAll(
'#header-main-bar .board-header-btns, #header-main-bar .board-header-btns-group',
),
).forEach(elem => elem.remove());
Array.from(
clonedElement.querySelectorAll('.js-pop-over, .pop-over'),
).forEach(elem => elem.remove());
Array.from(clonedElement.querySelectorAll('.list-composer')).forEach(elem =>
elem.remove(),
);
Array.from(
clonedElement.querySelectorAll(
'.list-composer,.js-card-composer, .js-add-card',
),
).forEach(elem => elem.remove());
// Remove edit/action buttons from cards but keep card content
Array.from(clonedElement.querySelectorAll('.js-edit, .js-delete, .js-open-card')).forEach(elem =>
elem.remove(),
);
// Keep card details structure for viewing - don't remove or hide
// The interactive script will handle showing/hiding them
Array.from(clonedElement.querySelectorAll('[href]:not(link)')).forEach(elem =>
elem.attributes.removeNamedItem('href'),
);
Array.from(clonedElement.querySelectorAll('[href]')).forEach(elem => {
// eslint-disable-next-line no-self-assign
elem.href = elem.href;
// eslint-disable-next-line no-self-assign
elem.src = elem.src;
});
Array.from(clonedElement.querySelectorAll('.is-editable')).forEach(elem => {
elem.classList.remove('is-editable');
});
};
const addJsonExportToZip = async (zip, boardSlug, zipDirName) => {
// The board's own JSON export. It used to be read off a .download-json-link
// in the export popup, which the one shared export popup no longer has, so
// the archive stopped here; the URL is built the way that link built it.
const boardId = Session.get('currentBoard');
const params = new URLSearchParams({ authToken: Accounts._storedLoginToken() || '' });
const response = await fetch(`/api/boards/${encodeURIComponent(boardId)}/export?${params.toString()}`);
const responseBody = await response.text();
zip.file(`${zipDirName}/data/${boardSlug}.json`, responseBody);
};
const getBoardSlug = () => {
// Use board ID from session instead of URL slug to keep filenames short
const boardId = Session.get('currentBoard');
if (boardId) {
return boardId;
}
// Fallback to URL slug if session is not available
return window.location.href.split('/').pop();
};
const getBoardTitle = () => {
// Extract from page header - most reliable method
const headerTitle = document.querySelector('h1.header-board-menu');
if (headerTitle && headerTitle.textContent) {
const title = headerTitle.textContent.trim();
// Filter out translation keys like "{{_ 'templates'}}"
if (title && !title.includes('{{')) {
return title;
}
}
// Fallback: use board slug if header title extraction fails
return getBoardSlug();
};
const sanitizeFilename = (filename) => {
// Remove or replace invalid filename characters
// Keep alphanumeric, hyphens, underscores, and spaces
return filename
.replace(/[^a-zA-Z0-9\s\-_]/g, '') // Remove special chars
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.substring(0, 100) // Limit to 100 chars
.trim();
};
const getStylesheetList = () => {
return Array.from(
document.querySelectorAll('link[href][rel="stylesheet"]'),
);
};
const getSrcAttached = () => {
return Array.from(document.querySelectorAll('[src]'));
};
const downloadStylesheets = async (stylesheets, zip, clonedHtmlElement, zipDirName) => {
await asyncForEach(stylesheets, async elem => {
const response = await fetch(elem.href);
let responseBody = await response.text();
// Check if this is a Font Awesome CSS file
const isFontAwesomeCSS = elem.href && (
elem.href.includes('fontawesome') ||
elem.href.includes('font-awesome') ||
elem.href.includes('fa-') ||
elem.href.includes('wekan-fontawesome')
);
if (isFontAwesomeCSS) {
// For Font Awesome: preserve @font-face but update paths to local files
responseBody = responseBody.replace(
/url\(["']?(?:\.\.\/)?webfonts\/([^"')]+)["']?\)/gi,
'url("../webfonts/$1")'
);
// Also handle upstream package paths for Font Awesome
responseBody = responseBody.replace(
/url\(["']?[^"']*packages\/[^/]+\/upstream\/webfonts\/([^"')]+)["']?\)/gi,
'url("../webfonts/$1")'
);
} else {
// For non-Font-Awesome CSS: remove @font-face rules to eliminate external font dependencies
responseBody = responseBody.replace(
/@font-face\s*\{[^}]*\}/gi,
''
);
// Replace non-Font-Awesome font families with system fonts (sans-serif)
// But preserve Font Awesome families
responseBody = responseBody.replace(
/font-family\s*:\s*[^;]*(?:Roboto|Lato|Open Sans)[^;]*;/gi,
'font-family: sans-serif;'
);
}
// Fix relative paths for assets
let finalResponse = responseBody;
// Replace upstream package paths
finalResponse = finalResponse.replace(
new RegExp('packages/[^/]+/upstream/', 'gim'),
'../',
);
const filename = elem.href
.split('/')
.pop()
.split('?')
.shift();
const fileFullPath = `${zipDirName}/style/${filename}`;
zip.file(fileFullPath, finalResponse);
// Update the cloned HTML element, not the live one
if (clonedHtmlElement) {
const clonedElements = clonedHtmlElement.querySelectorAll(`link[href="${elem.href}"]`);
clonedElements.forEach(clonedElem => {
clonedElem.href = `./style/${filename}`;
});
}
});
};
const downloadSrcAttached = async (elements, zip, boardSlug, clonedHtmlElement, zipDirName) => {
await asyncForEach(elements, async elem => {
const response = await fetch(elem.src);
const responseBody = await response.blob();
const filename = elem.src
.split('/')
.pop()
.split('?')
.shift();
const fileFullPath = `${zipDirName}/${elem.tagName.toLowerCase()}/${filename}`;
zip.file(fileFullPath, responseBody);
// Update the cloned HTML element, not the live one
if (clonedHtmlElement) {
const clonedElements = clonedHtmlElement.querySelectorAll(`[src="${elem.src}"]`);
clonedElements.forEach(clonedElem => {
clonedElem.src = `./${elem.tagName.toLowerCase()}/${filename}`;
});
}
});
};
const removeCssUrlSurround = url => {
const working = url || '';
return working
.split('url(')
.join('')
.split('")')
.join('')
.split('"')
.join('')
.split("')")
.join('')
.split("'")
.join('')
.split(')')
.join('');
};
const getCardCovers = () => {
return Array.from(document.querySelectorAll('.minicard-cover')).filter(
elem => elem.style['background-image'],
);
};
const getWebFonts = () => {
let fonts = [];
for (let sheet of document.styleSheets) {
// Get the base URL of the stylesheet
let baseUrl = sheet.href ? new URL(sheet.href).origin : window.location.origin;
try {
for (let rule of sheet.cssRules) {
if (rule.type === CSSRule.FONT_FACE_RULE) {
let src = rule.style.getPropertyValue('src');
let urlMatch = src.match(/url\(["']?(.+?)["']?\)/);
if (urlMatch) {
let fontUrl = urlMatch[1];
let fontFamily = rule.style.getPropertyValue('font-family').replace(/["']/g, '');
// Resolve the URL relative to the stylesheet's base URL
let resolvedUrl = new URL(fontUrl, baseUrl);
fonts.push({
url: resolvedUrl.href,
family: fontFamily
}); // Using .href to get the absolute URL
}
}
}
} catch (e) {
console.log('Access to stylesheet blocked:', e);
}
}
return fonts;
};
const downloadFonts = async(elements, zip, zipDirName) => {
let fontIndex = 1;
await asyncForEach(elements, async (fontObj, idx) => {
const elem = fontObj.url;
const fontFamily = fontObj.family || `font${fontIndex}`;
const response = await fetch(elem);
const responseBody = await response.blob();
// Try to extract a better filename
let filename = fontFamily.replace(/[^a-z0-9-]/gi, '').substring(0, 50) || `font${fontIndex++}`;
// If filename is empty after sanitization, use index
if (!filename || filename.length === 0) {
filename = `font${fontIndex++}`;
}
// First, try to extract filename from URL path (for normal URLs)
if (!elem.startsWith('data:')) {
const pathname = new URL(elem).pathname;
const urlFilename = pathname.split('/').pop().split('?')[0].split('#')[0];
if (urlFilename && urlFilename.length > 0 && !urlFilename.match(/^[a-f0-9]+$/i)) {
// Only use URL filename if it's not a hash
filename = urlFilename;
}
}
// Detect format from content-type or file extension
let extension = 'woff2';
const contentType = response.headers.get('content-type');
if (contentType) {
if (contentType.includes('font/woff')) extension = 'woff';
else if (contentType.includes('font/ttf')) extension = 'ttf';
else if (contentType.includes('application/x-font-ttf')) extension = 'ttf';
else if (contentType.includes('application/x-font-opentype')) extension = 'otf';
}
// Ensure filename has extension
if (!filename.includes('.')) {
filename = `${filename}.${extension}`;
}
const fileFullPath = `${zipDirName}/webfonts/${filename}`;
zip.file(fileFullPath, responseBody);
});
};
const downloadFontAwesomeFonts = async (zip, zipDirName) => {
// List of Font Awesome webfont files to include
const fontAwesomeFiles = [
'fa-solid-900.woff2',
'fa-solid-900.ttf',
'fa-brands-400.woff2',
'fa-brands-400.ttf',
'fa-regular-400.woff2',
'fa-regular-400.ttf',
'fa-v4compatibility.woff2',
'fa-v4compatibility.ttf',
];
try {
await asyncForEach(fontAwesomeFiles, async (filename) => {
try {
// Try to fetch from the Meteor bundle webfont location
const fontUrls = [
`/packages/wekan-fontawesome/webfonts/${filename}`,
`/webfonts/${filename}`,
`./webfonts/${filename}`,
`../packages/wekan-fontawesome/webfonts/${filename}`,
];
let response = null;
let fetchedUrl = null;
for (const url of fontUrls) {
try {
response = await fetch(url);
if (response.ok) {
fetchedUrl = url;
break;
}
} catch (e) {
// Try next URL
continue;
}
}
if (response && response.ok) {
const responseBody = await response.blob();
const fileFullPath = `${zipDirName}/webfonts/${filename}`;
zip.file(fileFullPath, responseBody);
console.log(`✓ Font Awesome font included: ${filename} from ${fetchedUrl}`);
} else {
console.log(`✗ Font Awesome font not found: ${filename}`);
}
} catch (e) {
console.log(`Error downloading Font Awesome font ${filename}:`, e);
}
});
} catch (e) {
console.log('Error in downloadFontAwesomeFonts:', e);
}
};
const downloadCardCovers = async (elements, zip, boardSlug, clonedHtmlElement, zipDirName) => {
await asyncForEach(elements, async elem => {
const response = await fetch(
removeCssUrlSurround(elem.style['background-image']),
);
const responseBody = await response.blob();
const filename = removeCssUrlSurround(elem.style['background-image'])
.split('/')
.pop()
.split('?')
.shift()
.split('#')
.shift();
const fileFullPath = `${zipDirName}/covers/${filename}`;
zip.file(fileFullPath, responseBody);
// Update the cloned HTML element, not the live one
if (clonedHtmlElement) {
const bgImage = removeCssUrlSurround(elem.style['background-image']);
const clonedElements = clonedHtmlElement.querySelectorAll('.minicard-cover');
clonedElements.forEach(clonedElem => {
if (removeCssUrlSurround(clonedElem.style['background-image']) === bgImage) {
clonedElem.style.backgroundImage = `url('./covers/${filename}')`;
}
});
}
});
};
const addBoardHTMLToZip = (boardSlug, zip, clonedElement, zipDirName) => {
ensureSidebarRemoved(clonedElement);
// Get the HTML string
let htmlString = removeAnchors(getPageHtmlString(clonedElement));
// Inject font styling with Font Awesome exception
// Apply sans-serif to most elements, but preserve Font Awesome icon fonts
const fontStyleTag = `<style>
* { font-family: sans-serif !important; }
body { font-family: sans-serif !important; }
.fa, .fas, .far, .fab, .fa-solid, .fa-regular, .fa-brands, [class*="fa-"] {
font-family: 'Font Awesome 6 Free', 'Font Awesome 6 Brands' !important;
}
</style>`;
htmlString = htmlString.replace(
/<\/head>/i,
`${fontStyleTag}</head>`
);
// Inject interactive script into the HTML before closing body tag
const scriptContent = `
// Make cards interactive in exported HTML
document.addEventListener('DOMContentLoaded', function() {
console.log('Initializing card interactions...');
// Create modal/popup for card details
let modal = document.getElementById('card-details-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'card-details-modal';
modal.style.cssText = 'display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;border:1px solid #ccc;border-radius:8px;padding:20px;max-width:600px;width:90%;max-height:80vh;overflow-y:auto;z-index:10000;box-shadow:0 4px 20px rgba(0,0,0,0.3);';
document.body.appendChild(modal);
}
// Add click handlers to cards to show/hide details
const cards = document.querySelectorAll('.minicard');
console.log('Found ' + cards.length + ' cards');
cards.forEach((card, idx) => {
card.style.cursor = 'pointer';
card.dataset.cardIndex = idx;
card.addEventListener('click', function(e) {
// Ignore clicks on buttons or action elements
if (e.target.closest('button, a, .js-') || e.target.closest('[role="button"]')) {
return;
}
e.preventDefault();
e.stopPropagation();
// Extract card title
const title = this.querySelector('.card-title, .minicard-title, [class*="title"]');
const titleText = title ? title.textContent.trim() : 'Card ' + this.dataset.cardIndex;
// Get all text content from the card
const allText = this.textContent.trim();
// Show modal with card details. Build it with DOM nodes and assign
// the card title/body through textContent — NEVER innerHTML. Those
// values come from .textContent, which DECODES HTML entities, so a
// card title holding entity-encoded markup (e.g.
// "&lt;img src=x onerror=...&gt;") decodes to a live tag; assigning it
// to innerHTML would re-parse and execute it — a stored XSS that fires
// when a recipient clicks the card in the exported HTML
// (GHSA-8r5p-4q9j-f5jx). textContent inserts them as inert text.
while (modal.firstChild) { modal.removeChild(modal.firstChild); }
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Close';
closeBtn.style.cssText = 'position:absolute;top:10px;right:10px;padding:5px 10px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;';
closeBtn.onclick = () => { modal.style.display = 'none'; document.body.style.overflow = 'auto'; };
modal.appendChild(closeBtn);
const content = document.createElement('div');
const titleEl = document.createElement('h2');
titleEl.style.marginBottom = '10px';
titleEl.textContent = titleText;
content.appendChild(titleEl);
const hr = document.createElement('hr');
hr.style.margin = '10px 0';
content.appendChild(hr);
const bodyEl = document.createElement('div');
bodyEl.style.cssText = 'white-space:pre-wrap;word-wrap:break-word;font-family:sans-serif;font-size:13px;max-height:400px;overflow-y:auto;line-height:1.4;';
bodyEl.textContent = allText;
content.appendChild(bodyEl);
const footEl = document.createElement('p');
footEl.style.cssText = 'color:#666;font-size:12px;margin-top:20px;';
const footEm = document.createElement('em');
footEm.textContent = 'Card #' + this.dataset.cardIndex + ' from exported board';
footEl.appendChild(footEm);
content.appendChild(footEl);
modal.appendChild(content);
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
console.log('Showing card details for card ' + this.dataset.cardIndex);
});
});
// Add style for cards
if (!document.getElementById('card-open-styles')) {
const style = document.createElement('style');
style.id = 'card-open-styles';
style.textContent = '* { font-family: sans-serif !important; } body { font-family: sans-serif !important; } .minicard { transition: all 0.3s ease; cursor: pointer; } .minicard:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15) !important; transform: translateY(-2px); }';
document.head.appendChild(style);
console.log('Added card styles and sans-serif font');
}
});
`;
// Inject script before closing body tag
htmlString = htmlString.replace(
/<\/body>/i,
`<script>${scriptContent}</script></body>`
);
const htmlOutputPath = `${zipDirName}/index.html`;
zip.file(
htmlOutputPath,
new Blob([htmlString], {
type: 'application/html',
}),
);
};
return async () => {
const zip = createZipEntries();
const boardSlug = getBoardSlug();
const boardTitle = sanitizeFilename(getBoardTitle());
const zipFilename = boardTitle || boardSlug;
const zipDirName = zipFilename; // Directory name inside the ZIP
// Render EVERY card first (infinite scroll otherwise leaves most cards out
// of the DOM), then snapshot.
await expandAllCardsForExport();
// Clone the HTML element to process for export without modifying live DOM
const htmlElement = window.document.querySelector('html');
const clonedHtmlElement = htmlElement.cloneNode(true);
await addJsonExportToZip(zip, boardSlug, zipDirName);
Popup.back();
// Process the cloned element only
cleanBoardHtmlClone(clonedHtmlElement);
// Pass cloned element and zipDirName to all download functions so they update the clone, not the live DOM
await downloadStylesheets(getStylesheetList(), zip, clonedHtmlElement, zipDirName);
await downloadSrcAttached(getSrcAttached(), zip, boardSlug, clonedHtmlElement, zipDirName);
await downloadCardCovers(getCardCovers(), zip, boardSlug, clonedHtmlElement, zipDirName);
// Download Font Awesome webfont files for icon support in exported HTML
await downloadFontAwesomeFonts(zip, zipDirName);
// Note: General fonts are not exported to keep export lightweight
// downloadFonts(getWebFonts(), zip, zipDirName); -- skipped
addBoardHTMLToZip(boardSlug, zip, clonedHtmlElement, zipDirName);
// Stream the .zip straight to disk (low memory) with a blob fallback.
await saveZipStreaming(zip, `${zipFilename}.zip`);
// No page reload - impersonation session is preserved!
};
};