/* global nhashFile */
const path = require('path')
const mime = require('mime-types')
const fs = require('fs')
const FN_VALIDATION_RE = /^[\w\-. ]+$/
/**
* Mime-types missed in `mime-db@1.52`
*
* @type {Map<string, string>}
*/
const MISSED_MIMES = new Map([
['.asice', 'application/vnd.etsi.asic-e+zip'],
['.asics', 'application/vnd.etsi.asic-s+zip'],
['.def', 'application/javascript; charset=UTF-8'] // UB forms definition
])
/**
* @classdesc
* Abstract interface for Virtual store. Must be implemented in descendants.
* Provide a way to store files in any manner developer want.
*/
/* BlobStoreItem sample:
{"store":"documents","fName":"contr_contractdoc document 3000000405832.pdf",
"origName":"Contract #01T.pdf",
"relPath":"435\\",
"ct":"application/pdf",
"size":2057405,
"md5":"3b44f38f6b120615604846b67150fcb0",
"revision":2}
*/
class BlobStoreCustom {
/**
* @param {Object} storeConfig
* @param {ServerApp} appInstance
* @param {UBSession} sessionInstance
* @param {object} [options]
*/
constructor (storeConfig, appInstance, sessionInstance, options) {
/** @type {ServerApp} */
this.App = appInstance
/** @type {UBSession} */
this.Session = sessionInstance
this.PROXY_SEND_FILE_HEADER = this.App.serverConfig.httpServer.reverseProxy.sendFileHeader
this.PROXY_SEND_FILE_LOCATION_ROOT = this.App.serverConfig.httpServer.reverseProxy.sendFileLocationRoot
/**
* Store parameters as defined in ubConfig
*/
this.config = Object.assign({}, storeConfig)
/**
* Name of store (from app config)
*/
this.name = this.config.name
/**
* Path to temp folder
* @type {string}
* @protected
*/
this.tempFolder = this.config.tempPath
/**
* How many previous revision is stored
* @type {number}
*/
this.historyDepth = this.config.historyDepth || 0
/**
* Set of allowed extensions (Example: '.jpg', '.png'). If null - any extension is allowed.
*
* @type {Set<string>|null}
*/
this.whileListExtensionSet = this.config.whitelistExtensions && this.config.whitelistExtensions.length
? new Set(this.config.whitelistExtensions)
: null
/**
* Set of prohibited extensions (Example: '.jpg', '.png')
*
* @type {Set<string>}
*/
this.blacklistExtensionsSet = this.config.blacklistExtensions && this.config.blacklistExtensions.length
? new Set(this.config.blacklistExtensions)
: new Set()
/**
* Enable access audit for external requests for this BLOB store class
* @type {boolean}
*/
this.accessAudit = this.App.domainInfo.has('uba_auditTrail') && (this.App.serverConfig.security.blobStoresAccessAudit === true)
/**
* Enable validation what uploaded PDF files do not contain JavaScript code
* @type {boolean}
*/
this.checkForMaliciousPDF = this.config.checkForMaliciousPDF === true
}
/**
* Save file content to temporary store with chunked upload support
*
* @abstract
* @param {BlobStoreRequest} request Request params
* @param {UBEntityAttribute} attribute
* @param {ArrayBuffer|THTTPRequest} content
* @param {object} [options]
* @param {boolean} options.contentIsFilePath
* @returns {BlobStoreItem}
*/
saveContentToTempStore (request, attribute, content, options = {}) {
const fn = this.getTempFileName(request)
const { chunksTotal, chunkNum, chunkSize } = request
let fileSize = 0
if (chunksTotal > 1 && chunksTotal !== chunkNum + 1) {
console.debug(`temp file's chunk (${chunkNum + 1} of ${chunksTotal}) will be written to`, fn)
} else {
console.debug('temp file will be written to', fn)
}
try {
let isFileExists = fs.existsSync(fn)
let isChunksValidErr = false
// Check exists, incomplete file state
if (isFileExists) {
if (chunkNum === 0) {
fs.unlinkSync(fn)
isFileExists = false
}
if (chunkNum > 0 && fs.statSync(fn).size !== chunkNum * chunkSize) isChunksValidErr = true
} else {
if (chunkNum > 0) isChunksValidErr = true
}
if (isChunksValidErr) {
throw new Error(`Chunks size validation error when uploading the file "${request.fileName}"`)
}
// Write file content
if (isFileExists) {
if (content.appendToFile) {
if (!content.appendToFile(fn)) throw new Error(`Error append to ${fn}`)
} else {
fs.appendFileSync(fn, content)
}
} else {
if (content.writeToFile) {
if (!content.writeToFile(fn)) throw new Error(`Error write to ${fn}`)
} else {
if (options && options.contentIsFilePath) {
global.copyFile(content, fn, true)
} else {
fs.writeFileSync(fn, content)
}
}
}
if (chunksTotal !== chunkNum + 1) return null
fileSize = fs.statSync(fn).size
} catch (e) {
if (fs.existsSync(fn)) fs.unlinkSync(fn)
throw e
}
const origFn = request.fileName || 'no-file-name.bin'
const ct = this.getMimeType(origFn)
if (this.checkForMaliciousPDF && (ct === 'application/pdf')) {
const buf = fs.readFileSync(fn)
const isBad = checkPdfHasEmbeddedJs(buf)
if (isBad) {
fs.unlinkSync(fn)
throw new Error('<<<attemptToUploadBadPDF>>>')
}
}
const newMD5 = nhashFile(fn, 'MD5')
return {
store: this.name,
fName: origFn,
origName: origFn,
ct,
size: fileSize,
md5: newMD5,
isDirty: true
}
}
/**
* Returns full path to the file with BLOB content
* @param {BlobStoreRequest} request
* @param {BlobStoreItem} blobInfo JSON retrieved from a DB
* @returns {string}
*/
getContentFilePath (request, blobInfo) {
return ''
}
/**
* Retrieve BLOB content from blob store.
* @abstract
* @param {BlobStoreRequest} request
* @param {BlobStoreItem} blobInfo JSON retrieved from a DB
* @param {object} [options]
* @param {string|null} [options.encoding] Possible values:
* 'bin' 'ascii' 'binary' 'hex' ucs2/ucs-2/utf16le/utf-16le utf8/utf-8
* if `null` will return {@link Buffer}, if `bin` - ArrayBuffer
* @returns {string|Buffer|ArrayBuffer|null}
*/
getContent (request, blobInfo, options) {}
/**
* Fill HTTP response for getDocument request. Sets resp to 404 status if content not found.
* @abstract
* @param {BlobStoreRequest} requestParams
* @param {BlobStoreItem} blobInfo
* @param {THTTPRequest} req
* @param {THTTPResponse} resp
* @param {boolean} [preventChangeRespOnError=false] If `true` - prevents sets resp status code - just returns false on error
* @returns {boolean}
*/
fillResponse (requestParams, blobInfo, req, resp, preventChangeRespOnError) { }
/**
* Move content defined by `dirtyItem` from temporary to permanent store.
* Return a new attribute content which describe a place of BLOB in permanent store
* @abstract
* @param {UBEntityAttribute} attribute
* @param {number} ID
* @param {BlobStoreItem} dirtyItem
* @param {number} newRevision
* @return {BlobStoreItem|null}
*/
persist (attribute, ID, dirtyItem, newRevision) { }
/**
* Do something with BLOB content during archiving. For example - move to slow drive etc.
* Default implementation do nothing.
* @param {UBEntityAttribute} attribute
* @param {number} ID
* @param {BlobStoreItem} blobInfo
* @returns {BlobStoreItem}
*/
doArchive (attribute, ID, blobInfo) {
return blobInfo
}
/**
* Delete persisted BLOB content
* @abstract
* @param {UBEntityAttribute} attribute
* @param {number} ID
* @param {BlobStoreItem} blobInfo
*/
doDeletion (attribute, ID, blobInfo) { }
/**
* Get path to temporary file and it's name
* @protected
* @param {BlobStoreRequest} request
* @returns {string}
*/
getTempFileName (request) {
// important to use Session.userID. See UB-617
return path.join(this.tempFolder, `${request.entity}_${request.attribute}_${request.ID}_${this.Session.userID}`)
}
/**
* Check file extension allowed by store whileList and not prohibited dy store blackList
*
* @param {string} ext
* @returns {boolean}
*/
extensionAllowed (ext) {
const el = ext.toLowerCase()
return this.whileListExtensionSet
? this.whileListExtensionSet.has(el) && !this.blacklistExtensionsSet.has(el)
: !this.blacklistExtensionsSet.has(el)
}
/**
* validate file name contains only alphanumeric characters, -, _, . and space and not contains ..
* @param {string} fn
* @throws throws in file name is not valid
*/
static validateFileName (fn) {
if (!FN_VALIDATION_RE.test(fn) || (fn.indexOf('..') !== -1)) {
const e = new Error(`Invalid file name '${fn}' for BLOB store`)
// emulate a ESecurityException
// eslint-disable-next-line n/no-deprecated-api
e.errorNumber = process.binding('ub_app').UBEXC_ESECURITY_EXCEPTION
throw e
}
}
/**
* Get mime type for passed file name or file extension. Recognize well known extensions missed in mime-db@1.52
* @param {string} fileNameOrExt
* @param {boolean} [isExtension=false] is first parameter a file extension (`.pdf` for example)
* @returns {string}
*/
getMimeType (fileNameOrExt, isExtension = false) {
const ext = isExtension ? fileNameOrExt : path.extname(fileNameOrExt)
if (MISSED_MIMES.has(ext)) {
return MISSED_MIMES.get(ext)
} else {
return mime.contentType(ext) || 'application/octet-stream'
}
}
}
/**
* Returns `true` in case PDF (loaded as buffer) contains embedded JavaScript
*
* @param {Buffer} buf
* @return {boolean}
*/
function checkPdfHasEmbeddedJs (buf) {
let p
let fromIndex = 0
const L = buf.length
// search for `\/JS\s+\(` pattern
do {
p = buf.indexOf('/JS', fromIndex)
if (p === -1) return false // no JavaScript
p += 3 // move to end of /JS tag
if (p > L) return false // not a script because of EEF
let v
do {
v = buf.readUInt8(p)
p++
} while ((p < L) && (v <= 32)) // skip all non-char
if (p >= L) return false // EOF
if (v === 40) return true // `(` sign
fromIndex = p
} while (p !== -1)
return false
}
module.exports = BlobStoreCustom