227 lines
7.6 KiB
JavaScript
227 lines
7.6 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import sharp from 'sharp';
|
|
import {
|
|
CREATE_FILE,
|
|
CREATE_ATTACHMENT,
|
|
GET_NEXT_SORT,
|
|
} from '../../sql/manifestattachment.js';
|
|
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
|
import resolveManifestAccess from './resolveManifestAccess.js';
|
|
import request from '../request.js';
|
|
|
|
export default function add(pool) {
|
|
const access = resolveSelectedCompanyAccess(pool);
|
|
const manifestAccess = resolveManifestAccess(pool);
|
|
|
|
/**
|
|
* Attaches a file to an existing manifest.
|
|
*
|
|
* @param {number} googleAuthId - The id of the Google auth user.
|
|
* @param {number} manifestId - The id of the manifest.
|
|
* @param {object} input - The file data.
|
|
* @param {string} input.mimetype - The MIME type of the file (e.g. `'application/pdf'`).
|
|
* @param {string} input.name - The file name.
|
|
* @param {Buffer} input.content - The file content.
|
|
* @param {number} [input.sort] - Optional display sort order.
|
|
* @param {number} [input.size] - Optional file size in bytes (defaults to content length).
|
|
* @param {Date|string} [input.created] - Optional creation timestamp (defaults to current date).
|
|
* @param {Date|string} [input.modified] - Optional modified timestamp (defaults to created date).
|
|
* @param {string} [input.hash] - The file hash (auto-computed SHA-256 if omitted).
|
|
* @param {Buffer} [input.thumbavif] - Optional AVIF thumbnail buffer.
|
|
* @param {number} [input.thumbx] - Optional thumbnail width.
|
|
* @param {number} [input.thumby] - Optional thumbnail height.
|
|
* @param {object} [input.meta] - Optional string key/value metadata stored on the file row.
|
|
* @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>} The created attachment record.
|
|
* @throws {Error} If the manifest is not found or file input is invalid.
|
|
*
|
|
* @example
|
|
* const attachment = await store.manifestattachment.add(42, 1, {
|
|
* mimetype: 'application/pdf',
|
|
* name: 'invoice.pdf',
|
|
* content: fileBuffer,
|
|
* });
|
|
* // => { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby }
|
|
*/
|
|
return async (googleAuthId, manifestId, input, ...rest) => {
|
|
let rawMimetype, rawName, rawContent, rawHash, rawThumbavif, rawThumbx, rawThumby, rawSort, rawSize, rawCreated, rawModified, rawMeta;
|
|
if (typeof input === 'object' && input !== null && !Buffer.isBuffer(input)) {
|
|
rawMimetype = input.mimetype;
|
|
rawName = input.name;
|
|
rawContent = input.content;
|
|
rawHash = input.hash;
|
|
rawThumbavif = input.thumbavif;
|
|
rawThumbx = input.thumbx ?? input.thumbw;
|
|
rawThumby = input.thumby ?? input.thumbh;
|
|
rawSort = input.sort;
|
|
rawSize = input.size;
|
|
rawCreated = input.created ?? input.createdAt;
|
|
rawModified = input.modified ?? input.modifiedAt ?? input.lastModified;
|
|
rawMeta = input.meta;
|
|
} else {
|
|
rawMimetype = input;
|
|
rawName = rest[0];
|
|
rawContent = rest[1];
|
|
}
|
|
|
|
const mimetype = typeof rawMimetype === 'string' ? rawMimetype.trim() : null;
|
|
if (!mimetype) {
|
|
throw new Error('Mimetype required');
|
|
}
|
|
|
|
const name = typeof rawName === 'string' ? rawName.trim() : null;
|
|
if (!name) {
|
|
throw new Error('File name required');
|
|
}
|
|
|
|
if (rawContent == null) {
|
|
throw new Error('Content required');
|
|
}
|
|
const content = Buffer.isBuffer(rawContent)
|
|
? rawContent
|
|
: Buffer.from(rawContent);
|
|
|
|
if (rawSort !== undefined && rawSort !== null) {
|
|
if (!Number.isFinite(rawSort)) {
|
|
throw new Error('Invalid sort');
|
|
}
|
|
}
|
|
|
|
const size =
|
|
Number.isFinite(rawSize) && rawSize >= 0
|
|
? Number(rawSize)
|
|
: content.length;
|
|
|
|
const parsedCreated =
|
|
rawCreated instanceof Date && !isNaN(rawCreated)
|
|
? rawCreated
|
|
: typeof rawCreated === 'string' || typeof rawCreated === 'number'
|
|
? new Date(rawCreated)
|
|
: null;
|
|
const created = parsedCreated && !isNaN(parsedCreated) ? parsedCreated : new Date();
|
|
|
|
const parsedModified =
|
|
rawModified instanceof Date && !isNaN(rawModified)
|
|
? rawModified
|
|
: typeof rawModified === 'string' || typeof rawModified === 'number'
|
|
? new Date(rawModified)
|
|
: null;
|
|
const modified = parsedModified && !isNaN(parsedModified) ? parsedModified : created;
|
|
|
|
// Simple string key/value metadata; only plain string values are kept.
|
|
let meta = null;
|
|
if (typeof rawMeta === 'object' && rawMeta !== null && !Array.isArray(rawMeta)) {
|
|
meta = {};
|
|
for (const [key, value] of Object.entries(rawMeta)) {
|
|
if (value === undefined || value === null) continue;
|
|
meta[key] = String(value);
|
|
}
|
|
if (Object.keys(meta).length === 0) {
|
|
meta = null;
|
|
}
|
|
}
|
|
|
|
const hash =
|
|
typeof rawHash === 'string' && rawHash.trim()
|
|
? rawHash.trim()
|
|
: createHash('sha256').update(content).digest('hex');
|
|
|
|
let thumbavif = rawThumbavif
|
|
? (Buffer.isBuffer(rawThumbavif) ? rawThumbavif : Buffer.from(rawThumbavif))
|
|
: null;
|
|
let thumbx = Number.isFinite(rawThumbx) ? Number(rawThumbx) : null;
|
|
let thumby = Number.isFinite(rawThumby) ? Number(rawThumby) : null;
|
|
|
|
if (thumbavif && (!thumbx || !thumby)) {
|
|
try {
|
|
const meta = await sharp(thumbavif).metadata();
|
|
thumbx = thumbx ?? meta.width ?? null;
|
|
thumby = thumby ?? meta.height ?? null;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!thumbavif) {
|
|
try {
|
|
const thumbResult = await sharp(content)
|
|
.resize(200, 200, { fit: 'inside', withoutEnlargement: true })
|
|
.avif({ effort: 4 })
|
|
.toBuffer({ resolveWithObject: true });
|
|
thumbavif = thumbResult.data;
|
|
thumbx = thumbResult.info.width;
|
|
thumby = thumbResult.info.height;
|
|
} catch {
|
|
thumbavif = null;
|
|
thumbx = null;
|
|
thumby = null;
|
|
}
|
|
}
|
|
|
|
const { companyId } = await access(googleAuthId);
|
|
await manifestAccess(companyId, manifestId);
|
|
|
|
const tx = pool.transaction();
|
|
await tx.begin();
|
|
|
|
try {
|
|
let sort = rawSort;
|
|
if (sort === undefined || sort === null) {
|
|
const sortRes = await request(pool, tx)
|
|
.input('manifestId', manifestId)
|
|
.query(GET_NEXT_SORT);
|
|
sort = sortRes.recordset[0].sort;
|
|
}
|
|
|
|
const fileRes = await request(pool, tx)
|
|
.input('hash', hash)
|
|
.input('mimetype', mimetype)
|
|
.input('name', name)
|
|
.input('size', size)
|
|
.input('created', created)
|
|
.input('modified', modified)
|
|
.input('content', content)
|
|
.input('thumbavif', thumbavif)
|
|
.input('thumbx', thumbx)
|
|
.input('thumby', thumby)
|
|
.input('meta', meta === null ? null : JSON.stringify(meta))
|
|
.query(CREATE_FILE);
|
|
|
|
const fileId = fileRes.recordset[0].id;
|
|
|
|
const attachmentRes = await request(pool, tx)
|
|
.input('manifestId', manifestId)
|
|
.input('fileId', fileId)
|
|
.input('sort', sort)
|
|
.query(CREATE_ATTACHMENT);
|
|
|
|
const attachmentId = attachmentRes.recordset[0].id;
|
|
|
|
await tx.commit();
|
|
|
|
return {
|
|
id: attachmentId,
|
|
manifestId,
|
|
fileId,
|
|
sort,
|
|
hash,
|
|
mimetype,
|
|
name,
|
|
size,
|
|
created,
|
|
modified,
|
|
thumbavif,
|
|
thumbx,
|
|
thumby,
|
|
meta,
|
|
};
|
|
} catch (err) {
|
|
try {
|
|
await tx.rollback();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
}
|