import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import sharp from "sharp"; import { fileTypeFromBuffer } from "file-type"; import type { Db } from "../db.js"; import { nowIso } from "../db.js"; import { newId, ID_PREFIX } from "../domain/ids.js"; import type { SourceRole } from "../domain/types.js"; import type { AppConfig } from "../config.js"; const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]); const IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/webp"]); const EXT_BY_MIME: Record = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "application/pdf": "pdf", }; const MAX_DIMENSION = 12000; const THUMB_WIDTH = 480; export interface IngestedSource { id: string; sha256: string; mime: string; bytes: number; width: number | null; height: number | null; path: string; thumbPath: string | null; duplicate: boolean; /** duplikat przypięty linkiem do innego produktu (ta sama treść, inny właściciel) */ linked?: boolean; } export class IngestError extends Error { statusCode: number; constructor(message: string, statusCode = 400) { super(message); this.statusCode = statusCode; } } /** * Pipeline uploadu: rzeczywisty MIME z bajtów (nie z nagłówka), limit * rozmiaru i wymiarów, normalizacja orientacji EXIF, sha256 dedup. * Oryginał zapisywany raz, pod niezmienną ścieżką od treści. */ export async function ingestUpload( db: Db, cfg: AppConfig, input: { buffer: Buffer; originalName: string; declaredMime?: string; productId?: string | null; role: SourceRole; note?: string | null; uploadedBy?: string | null; /** URL, z którego pobrano materiał (import vendora) */ sourceUrl?: string | null; /** kiedy pobrano z sourceUrl — ISO */ fetchedAt?: string | null; } ): Promise { if (input.buffer.length === 0) throw new IngestError("Pusty plik."); if (input.buffer.length > cfg.maxUploadBytes) throw new IngestError("Plik przekracza limit 25 MB.", 413); const detected = await fileTypeFromBuffer(input.buffer); if (!detected || !ALLOWED_MIME.has(detected.mime)) { throw new IngestError( `Nieobsługiwany typ pliku${detected ? ` (${detected.mime})` : ""}. Dozwolone: JPEG, PNG, WebP, PDF.`, 415 ); } let width: number | null = null; let height: number | null = null; let normalized: Buffer = input.buffer; if (IMAGE_MIME.has(detected.mime)) { let img = sharp(input.buffer, { failOn: "error" }); const meta = await img.metadata(); if (!meta.width || !meta.height) throw new IngestError("Nie można odczytać wymiarów obrazu."); if (meta.width > MAX_DIMENSION || meta.height > MAX_DIMENSION) { throw new IngestError(`Wymiary ${meta.width}×${meta.height} przekraczają limit ${MAX_DIMENSION}px.`, 413); } // Normalizacja orientacji EXIF — oryginał zapisujemy już obrócony, // bo zdjęcia z hali z telefonu prawie zawsze mają orientation tag. normalized = await img.rotate().toBuffer(); const rotatedMeta = await sharp(normalized).metadata(); width = rotatedMeta.width ?? meta.width; height = rotatedMeta.height ?? meta.height; } const sha256 = crypto.createHash("sha256").update(normalized).digest("hex"); const existing = db .prepare("SELECT * FROM source_assets WHERE sha256 = ?") .get(sha256) as unknown as { id: string; path: string; thumb_path: string | null; product_id: string | null } | undefined; if (existing) { // Ta sama treść pod innym produktem → link, nie nowy plik ani błąd. // Bez tego import tego samego mebla pod drugim slugiem kończył się // komunikatem „identyczne bajty już zapisane przy innym produkcie" // i pustą galerią nowego produktu. if (input.productId && existing.product_id !== input.productId) { db.prepare( "INSERT OR IGNORE INTO source_asset_products (source_id, product_id, created_at) VALUES (?, ?, ?)" ).run(existing.id, input.productId, nowIso()); } return { id: existing.id, sha256, mime: detected.mime, bytes: normalized.length, width, height, path: existing.path, thumbPath: existing.thumb_path, duplicate: true, linked: Boolean(input.productId && existing.product_id !== input.productId), }; } const ext = EXT_BY_MIME[detected.mime]!; const relPath = path.join(sha256.slice(0, 2), `${sha256}.${ext}`); const absPath = path.join(cfg.originalsDir, relPath); fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, normalized, { flag: "wx" }); let thumbRel: string | null = null; if (IMAGE_MIME.has(detected.mime)) { const thumbName = `${sha256}-w${THUMB_WIDTH}.webp`; const thumbAbs = path.join(cfg.derivedDir, sha256.slice(0, 2), thumbName); fs.mkdirSync(path.dirname(thumbAbs), { recursive: true }); await sharp(normalized) .resize({ width: THUMB_WIDTH, withoutEnlargement: true }) .webp({ quality: 82 }) .toFile(thumbAbs); thumbRel = path.relative(cfg.derivedDir, thumbAbs); } const id = newId(ID_PREFIX.source); const now = nowIso(); db.prepare( `INSERT INTO source_assets (id, product_id, role, sha256, mime, bytes, width, height, original_name, path, thumb_path, note, uploaded_by, created_at, source_url, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, input.productId ?? null, input.role, sha256, detected.mime, normalized.length, width, height, input.originalName, relPath, thumbRel, input.note ?? null, input.uploadedBy ?? null, now, input.sourceUrl ?? null, input.fetchedAt ?? null ); return { id, sha256, mime: detected.mime, bytes: normalized.length, width, height, path: relPath, thumbPath: thumbRel, duplicate: false }; } /** Źródła produktu = własne (product_id) + linkowane (source_asset_products). */ export function listSources(db: Db, productId: string) { return db .prepare( `SELECT sa.*, CASE WHEN sa.product_id = ? THEN 0 ELSE 1 END AS linked FROM source_assets sa WHERE sa.product_id = ? OR EXISTS (SELECT 1 FROM source_asset_products l WHERE l.source_id = sa.id AND l.product_id = ?) ORDER BY sa.created_at DESC` ) .all(productId, productId, productId); } /** Czy źródło należy do produktu (własność lub link)? */ export function sourceBelongsToProduct(db: Db, sourceId: string, productId: string): boolean { return Boolean( db .prepare( `SELECT 1 AS x FROM source_assets sa WHERE sa.id = ? AND (sa.product_id = ? OR EXISTS (SELECT 1 FROM source_asset_products l WHERE l.source_id = sa.id AND l.product_id = ?))` ) .get(sourceId, productId, productId) ); }