This commit is contained in:
seb
2026-10-08 02:53:32 +02:00
parent cd396363bd
commit 73461c6e6f
6 changed files with 751 additions and 2 deletions

View File

@@ -1,7 +1,41 @@
import fs from 'fs';
import path from 'path';
import multer from 'multer';
import JSZip from 'jszip';
import * as db from '../../db/company.js';
import * as googleAuth from '../../db/googleAuth.js';
import { clearCachedUser } from '../middleware/auth.js';
import { initCompanyVfs } from '../../tools/vfs.js';
import { executeCompanyImport } from '../../services/companyImport.js';
const uploadZip = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB
fileFilter: (_req, file, cb) => {
const name = file.originalname?.toLowerCase() || '';
const isZipExt = name.endsWith('.zip');
const isZipMime =
file.mimetype === 'application/zip' ||
file.mimetype === 'application/x-zip-compressed';
if (isZipExt || isZipMime) {
cb(null, true);
} else {
cb(new Error('Only .zip archives are accepted'));
}
},
}).single('file');
function uploadCompanyImportFile(req, res, next) {
uploadZip(req, res, (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large (max 100 MB)' });
}
return res.status(400).json({ error: err.message });
}
next();
});
}
function userPayload(authRow) {
return {
@@ -348,6 +382,201 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /company/import:
* post:
* tags: [Company]
* summary: Import company catalog from zip archive (SSE)
* description: |
* Uploads a .zip archive containing company export data (categories, articles, pictures),
* preserves archive contents under `data/import/<companyId>/`, and executes full catalog
* import (categories hierarchy, articles upsert, variant linking, image processing).
* Streams progress events via Server-Sent Events (`text/event-stream`).
* security:
* - cookieAuth: []
* x-markdown: company/import.md
* requestBody:
* required: false
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* file:
* type: string
* format: binary
* description: ZIP archive (.zip) containing company data
* responses:
* 200:
* description: Import progress stream (`text/event-stream`) or JSON result
* content:
* text/event-stream:
* schema:
* type: string
* application/json:
* schema:
* type: object
* required: [ok, message]
* properties:
* ok:
* type: boolean
* example: true
* message:
* type: string
* example: Import erfolgreich abgeschlossen.
* stats:
* type: object
* 400:
* description: Bad request (missing file, not a zip, or no company selected)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.post(
`${basePath}/company/import`,
uploadCompanyImportFile,
async (req, res) => {
let companyId;
try {
companyId = requireSelectedCompany(req);
} catch (error) {
return res.status(error.statusCode || 400).json({ error: error.message });
}
const importDir = path.resolve(process.cwd(), 'data/import', String(companyId));
// If a file was uploaded, unpack and preserve it
if (req.file?.buffer) {
const originalName = req.file.originalname || 'import.zip';
if (!originalName.toLowerCase().endsWith('.zip')) {
return res.status(400).json({ error: 'Uploaded file must have a .zip extension' });
}
let zip;
try {
zip = await JSZip.loadAsync(req.file.buffer);
} catch (err) {
return res.status(400).json({ error: 'Invalid or corrupt zip archive' });
}
try {
fs.rmSync(importDir, { recursive: true, force: true });
fs.mkdirSync(importDir, { recursive: true });
// Preserve the raw zip archive
fs.writeFileSync(path.join(importDir, 'import.zip'), req.file.buffer);
console.log(`[Company Import] Extracting archive "${originalName}" into ${importDir}:`);
let fileCount = 0;
let dirCount = 0;
for (const [relativePath, entry] of Object.entries(zip.files)) {
const safePath = path.normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, '');
const destPath = path.resolve(importDir, safePath);
if (!destPath.startsWith(importDir)) {
console.warn(`[Company Import] Skipped unsafe path: ${relativePath}`);
continue;
}
if (entry.dir) {
dirCount++;
fs.mkdirSync(destPath, { recursive: true });
} else {
fileCount++;
fs.mkdirSync(path.dirname(destPath), { recursive: true });
const content = await entry.async('nodebuffer');
fs.writeFileSync(destPath, content);
}
}
console.log(`[Company Import] Preserved ${fileCount} files and ${dirCount} directories in ${importDir}`);
} catch (err) {
console.error('[Company Import] Failed to extract archive:', err);
return res.status(500).json({ error: 'Failed to extract import archive' });
}
} else {
// No file uploaded in this request; check if existing preserved files are present
const catFile = path.join(importDir, 'categories.csv');
const artFile = path.join(importDir, 'articles.csv');
if (!fs.existsSync(catFile) || !fs.existsSync(artFile)) {
return res.status(400).json({ error: 'Zip file is required in "file" form field' });
}
}
const wantsSse =
req.headers.accept?.includes('text/event-stream') ||
req.query.stream === '1' ||
req.query.sse === '1' ||
!req.headers.accept?.includes('application/json');
if (wantsSse) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
const sseWrite = (event, data) => {
if (res.writableEnded || res.destroyed) return;
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
sseWrite('status', { message: 'Import gestartet. Daten werden verarbeitet...' });
try {
const outcome = await executeCompanyImport({
googleAuthId: req.user.id,
companyId,
importDir,
onProgress: (phase, current, total, item) => {
sseWrite('progress', { phase, current, total, item });
},
});
sseWrite('done', outcome);
res.end();
} catch (err) {
console.error('[Company Import] Import execution failed:', err);
sseWrite('error', { message: err.message || 'Import fehlgeschlagen' });
res.end();
}
} else {
try {
const outcome = await executeCompanyImport({
googleAuthId: req.user.id,
companyId,
importDir,
onProgress: (phase, current, total, item) => {
console.log(`[Import Progress] [${phase}] ${current}/${total} - ${item}`);
},
});
res.status(200).json(outcome);
} catch (err) {
console.error('[Company Import] Import execution failed:', err);
res.status(500).json({ error: err.message || 'Import fehlgeschlagen' });
}
}
}
);
/**
* @openapi
* /company/{id}: