u
This commit is contained in:
@@ -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}:
|
||||
|
||||
Reference in New Issue
Block a user