268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
|
|
const { logger } = require('../../middleware/logger');
|
|
const crypto = require('crypto');
|
|
|
|
const repo = new CompanySettingsRepository();
|
|
|
|
class CompanySettingsController {
|
|
static _normalizeBase64ImageInput(value) {
|
|
if (value === undefined) return undefined;
|
|
if (value === null) return null;
|
|
|
|
const stripDataUri = (str) => {
|
|
const s = String(str || '').trim();
|
|
if (!s) return '';
|
|
const m = s.match(/^data:([^;]+);base64,(.*)$/i);
|
|
return m ? (m[2] || '').trim() : s;
|
|
};
|
|
|
|
if (typeof value === 'string') {
|
|
// If base64 came via urlencoded forms, '+' often becomes ' ' after decoding.
|
|
// Restoring spaces back to '+' makes the payload usable again.
|
|
let normalized = stripDataUri(value);
|
|
if (normalized.includes(' ') && !normalized.includes('+')) {
|
|
normalized = normalized.replace(/ /g, '+');
|
|
}
|
|
normalized = normalized.replace(/\s+/g, '');
|
|
return normalized || null;
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
// Common Node.js JSON shape for Buffers: { type: 'Buffer', data: [..bytes..] }
|
|
if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
|
|
try {
|
|
const buf = Buffer.from(value.data);
|
|
const asBase64 = buf.toString('base64');
|
|
return asBase64 || null;
|
|
} catch (_) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Common frontend shapes:
|
|
// { kind: 'base64', base64: '...' }
|
|
// { kind: 'base64', data: '...' }
|
|
// { dataUrl: 'data:image/png;base64,...' }
|
|
// { value: '...' }
|
|
const candidates = [
|
|
value.base64,
|
|
value.data,
|
|
value.value,
|
|
value.content,
|
|
value.full,
|
|
value.raw,
|
|
value.payload,
|
|
value.src,
|
|
value.b64,
|
|
value.base64String,
|
|
value.dataUrl,
|
|
value.dataURL,
|
|
value.data_uri,
|
|
value.dataURI,
|
|
value.uri,
|
|
];
|
|
for (const c of candidates) {
|
|
if (typeof c === 'string' && c.trim()) {
|
|
let normalized = stripDataUri(c);
|
|
if (normalized.includes(' ') && !normalized.includes('+')) {
|
|
normalized = normalized.replace(/ /g, '+');
|
|
}
|
|
normalized = normalized.replace(/\s+/g, '');
|
|
return normalized || null;
|
|
}
|
|
}
|
|
|
|
// Fallback: scan shallow object for a base64-ish string
|
|
const looksLikeImageBase64 = (str) => {
|
|
const s = String(str || '').trim();
|
|
return s.startsWith('data:image/') || s.startsWith('iVBORw0KGgo') || s.startsWith('/9j/') || s.startsWith('R0lGOD');
|
|
};
|
|
|
|
for (const v of Object.values(value)) {
|
|
if (typeof v === 'string' && looksLikeImageBase64(v)) {
|
|
let normalized = stripDataUri(v);
|
|
if (normalized.includes(' ') && !normalized.includes('+')) {
|
|
normalized = normalized.replace(/ /g, '+');
|
|
}
|
|
normalized = normalized.replace(/\s+/g, '');
|
|
return normalized || null;
|
|
}
|
|
if (v && typeof v === 'object') {
|
|
for (const vv of Object.values(v)) {
|
|
if (typeof vv === 'string' && looksLikeImageBase64(vv)) {
|
|
let normalized = stripDataUri(vv);
|
|
if (normalized.includes(' ') && !normalized.includes('+')) {
|
|
normalized = normalized.replace(/ /g, '+');
|
|
}
|
|
normalized = normalized.replace(/\s+/g, '');
|
|
return normalized || null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unknown shape; ignore instead of persisting garbage
|
|
return undefined;
|
|
}
|
|
|
|
static async get(req, res) {
|
|
try {
|
|
const settings = await repo.get();
|
|
return res.json(settings || {
|
|
company_name: '',
|
|
company_street: '',
|
|
company_postal_city: '',
|
|
company_country: '',
|
|
qr_code_60_base64: null,
|
|
qr_code_120_base64: null,
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Failed to load company settings' });
|
|
}
|
|
}
|
|
|
|
static async update(req, res) {
|
|
try {
|
|
const body = req.body || {};
|
|
|
|
const requestId = req.id;
|
|
|
|
const contentType = (req.get('content-type') || '').toLowerCase();
|
|
const contentLength = req.get('content-length') || null;
|
|
if (contentType.includes('multipart/form-data')) {
|
|
logger.warn('companySettings:update:multipart_not_supported', {
|
|
requestId,
|
|
contentType: req.get('content-type') || null,
|
|
hint: 'Send JSON (application/json) or add multer middleware for this route.'
|
|
});
|
|
}
|
|
if (Object.keys(body).length === 0 && contentLength && Number(contentLength) > 0) {
|
|
logger.warn('companySettings:update:empty_parsed_body', {
|
|
requestId,
|
|
contentType: req.get('content-type') || null,
|
|
contentLength,
|
|
hint: 'Body parser may not match the request content-type or size limit.'
|
|
});
|
|
}
|
|
|
|
const summarizeValue = (val) => {
|
|
const t = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val;
|
|
const summary = { type: t };
|
|
if (t === 'string') {
|
|
const s = String(val);
|
|
summary.length = s.length;
|
|
summary.hasDataUriPrefix = s.trim().toLowerCase().startsWith('data:image/');
|
|
summary.hasWhitespace = /\s/.test(s);
|
|
summary.startsWithPngSig = s.trim().startsWith('iVBORw0KGgo');
|
|
} else if (t === 'array') {
|
|
summary.length = val.length;
|
|
} else if (t === 'object' && val) {
|
|
summary.keys = Object.keys(val).slice(0, 30);
|
|
if (val.type === 'Buffer' && Array.isArray(val.data)) {
|
|
summary.bufferLike = true;
|
|
summary.dataLength = val.data.length;
|
|
}
|
|
}
|
|
return summary;
|
|
};
|
|
|
|
const hash12 = (s) => {
|
|
try {
|
|
if (typeof s !== 'string' || !s) return null;
|
|
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Log request shape (not the base64 itself)
|
|
const incoming60Raw = body.qr_code_60_base64 ?? body.qrCode60Base64;
|
|
const incoming120Raw = body.qr_code_120_base64 ?? body.qrCode120Base64;
|
|
if (incoming60Raw !== undefined || incoming120Raw !== undefined) {
|
|
logger.info('companySettings:update:incoming_qr', {
|
|
requestId,
|
|
contentType: req.get('content-type') || null,
|
|
contentLength,
|
|
bodyKeys: Object.keys(body).slice(0, 50),
|
|
qr60: summarizeValue(incoming60Raw),
|
|
qr120: summarizeValue(incoming120Raw),
|
|
});
|
|
}
|
|
|
|
// Accept both snake_case and camelCase
|
|
const payload = {
|
|
company_name: body.company_name ?? body.companyName,
|
|
company_street: body.company_street ?? body.companyStreet,
|
|
company_postal_city: body.company_postal_city ?? body.companyPostalCity,
|
|
company_country: body.company_country ?? body.companyCountry,
|
|
qr_code_60_base64: CompanySettingsController._normalizeBase64ImageInput(body.qr_code_60_base64 ?? body.qrCode60Base64),
|
|
qr_code_120_base64: CompanySettingsController._normalizeBase64ImageInput(body.qr_code_120_base64 ?? body.qrCode120Base64),
|
|
};
|
|
|
|
// Only forward keys that were actually provided (so we don't wipe values on partial updates)
|
|
const provided = {};
|
|
for (const [key, value] of Object.entries(payload)) {
|
|
if (value !== undefined) provided[key] = value;
|
|
}
|
|
|
|
// Debug without leaking base64
|
|
if (incoming60Raw !== undefined && provided.qr_code_60_base64 === undefined) {
|
|
logger.warn('companySettings:update:qr60_ignored', {
|
|
requestId,
|
|
incoming: summarizeValue(incoming60Raw),
|
|
reason: 'normalize_returned_undefined_or_unrecognized_shape'
|
|
});
|
|
}
|
|
if (incoming120Raw !== undefined && provided.qr_code_120_base64 === undefined) {
|
|
logger.warn('companySettings:update:qr120_ignored', {
|
|
requestId,
|
|
incoming: summarizeValue(incoming120Raw),
|
|
reason: 'normalize_returned_undefined_or_unrecognized_shape'
|
|
});
|
|
}
|
|
|
|
if (provided.qr_code_60_base64 !== undefined || provided.qr_code_120_base64 !== undefined) {
|
|
const len60 = typeof provided.qr_code_60_base64 === 'string' ? provided.qr_code_60_base64.length : null;
|
|
const len120 = typeof provided.qr_code_120_base64 === 'string' ? provided.qr_code_120_base64.length : null;
|
|
logger.info('companySettings:update:qr_normalized', {
|
|
requestId,
|
|
has60: provided.qr_code_60_base64 !== undefined,
|
|
type60: provided.qr_code_60_base64 === null ? 'null' : typeof provided.qr_code_60_base64,
|
|
len60,
|
|
sha60: typeof provided.qr_code_60_base64 === 'string' ? hash12(provided.qr_code_60_base64) : null,
|
|
has120: provided.qr_code_120_base64 !== undefined,
|
|
type120: provided.qr_code_120_base64 === null ? 'null' : typeof provided.qr_code_120_base64,
|
|
len120,
|
|
sha120: typeof provided.qr_code_120_base64 === 'string' ? hash12(provided.qr_code_120_base64) : null,
|
|
});
|
|
}
|
|
|
|
const updated = await repo.update(provided);
|
|
|
|
if (updated && (provided.qr_code_60_base64 !== undefined || provided.qr_code_120_base64 !== undefined)) {
|
|
const storedLen60 = typeof updated.qr_code_60_base64 === 'string' ? updated.qr_code_60_base64.length : null;
|
|
const storedLen120 = typeof updated.qr_code_120_base64 === 'string' ? updated.qr_code_120_base64.length : null;
|
|
logger.info('companySettings:update:qr_stored', {
|
|
requestId,
|
|
storedLen60,
|
|
storedSha60: typeof updated.qr_code_60_base64 === 'string' ? hash12(updated.qr_code_60_base64) : null,
|
|
storedLen120,
|
|
storedSha120: typeof updated.qr_code_120_base64 === 'string' ? hash12(updated.qr_code_120_base64) : null,
|
|
});
|
|
}
|
|
|
|
return res.json(updated);
|
|
} catch (err) {
|
|
logger.error('companySettings:update:failed', {
|
|
requestId: req && req.id,
|
|
message: err?.message,
|
|
stack: err?.stack,
|
|
});
|
|
return res.status(500).json({ message: 'Failed to update company settings' });
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = CompanySettingsController;
|