@mieweb/wikigdrive
Version:
Google Drive to MarkDown synchronization
668 lines (611 loc) • 20.2 kB
text/typescript
import process from 'node:process';
import { Buffer } from 'node:buffer';
import fs, {ReadStream, WriteStream} from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import EventEmitter from 'node:events';
import {StreamOptions} from 'node:stream';
import dayjs from 'dayjs';
interface AuditFile {
name: string;
date: number;
hash: string;
hashType?: string;
}
interface Audit {
hashType: string;
files: AuditFile[];
keep: {
days: number;
amount: number;
}
}
/**
* FileStreamRotator:
*
* Returns a file stream that auto-rotates based on date.
*
* Options:
*
* - `filename` Filename including full path used by the stream
*
* - `frequency` How often to rotate. Options are 'daily', 'custom' and 'test'. 'test' rotates every minute.
* If frequency is set to none of the above, a YYYYMMDD string will be added to the end of the filename.
*
* - `verbose` If set, it will log to STDOUT when it rotates files and name of log file. Default is TRUE.
*
* - `date_format` Format as used in moment.js http://momentjs.com/docs/#/displaying/format/. The result is used to replace
* the '%DATE%' placeholder in the filename.
* If using 'custom' frequency, it is used to trigger file change when the string representation changes.
*
* - `size` Max size of the file after which it will rotate. It can be combined with frequency or date format.
* The size units are 'k', 'm' and 'g'. Units need to directly follow a number e.g. 1g, 100m, 20k.
*
* - `max_logs` Max number of logs to keep. If not set, it won't remove past logs. It uses its own log audit file
* to keep track of the log files in a json format. It won't delete any file not contained in it.
* It can be a number of files or number of days. If using days, add 'd' as the suffix.
*
* - `audit_file` Location to store the log audit file. If not set, it will be stored in the root of the application.
*
* - `end_stream` End stream (true) instead of the default behaviour of destroy (false). Set value to true if when writing to the
* stream in a loop, if the application terminates or log rotates, data pending to be flushed might be lost.
*
* - `file_options` An object passed to the stream. This can be used to specify flags, encoding, and mode.
* See https://nodejs.org/api/fs.html#fs_fs_createwritestream_path_options. Default `{ flags: 'a' }`.
*
* - `utc` Use UTC time for date in filename. Defaults to 'FALSE'
*
* - `extension` File extension to be appended to the filename. This is useful when using size restrictions as the rotation
* adds a count (1,2,3,4,...) at the end of the filename when the required size is met.
*
* - `watch_log` Watch the current file being written to and recreate it in case of accidental deletion. Defaults to 'FALSE'
*
* - `create_symlink` Create a tailable symlink to the current active log file. Defaults to 'FALSE'
*
* - `symlink_name` Name to use when creating the symbolic link. Defaults to 'current.log'
*
* - `audit_hash_type` Use specified hashing algorithm for audit. Defaults to 'md5'. Use 'sha256' for FIPS compliance.
*
* To use with Express / Connect, use as below.
*
* const rotatingLogStream from 'FileStreamRotator').getStream({filename:"/tmp/test.log", frequency:"daily", verbose: false})
* app.use(express.logger({stream: rotatingLogStream, format: "default"}));
*
* @param {Object} options
* @return {Object}
* @api public
*/
export class FileStreamRotator extends EventEmitter {
private readonly frequencyMetaData: boolean | { type: any; digit: number };
private readonly verbose: boolean;
private readonly fileSize: number;
private readonly filename: string;
private readonly dateFormat: string;
private readonly file_options: StreamOptions<ReadStream>;
private fileCount: number;
private curSize: number;
private curDate: string;
private oldFile: string;
private logfile: string;
private rotateStream: WriteStream;
private auditLog: any;
constructor(private readonly options) {
super();
this.frequencyMetaData = options.frequency ? getFrequency(options.frequency) : null;
const auditLog = setAuditLog(options.max_logs, options.audit_file, options.filename);
// Thanks to Means88 for PR.
if (auditLog != null) {
auditLog.hashType = (options.audit_hash_type !== undefined ? options.audit_hash_type : 'md5');
}
this.verbose = (options.verbose !== undefined ? options.verbose : true);
this.curDate = null;
this.fileCount = 0;
this.curSize = 0;
this.fileSize = options.size ? parseFileSize(options.size) : null;
this.dateFormat = (options.date_format || DATE_FORMAT);
if (this.frequencyMetaData && this.frequencyMetaData.type === 'daily') {
if (!options.date_format) {
this.dateFormat = 'YYYY-MM-DD';
}
if (dayjs().format(this.dateFormat) != dayjs().endOf('day').format(this.dateFormat) ||
dayjs().format(this.dateFormat) == dayjs().add(1,'day').format(this.dateFormat)) {
if (this.verbose) {
console.log(new Date(), '[FileStreamRotator] Changing type to custom as date format changes more often than once a day or not every day');
}
this.frequencyMetaData.type = 'custom';
}
}
if (this.frequencyMetaData) {
this.curDate = (options.frequency ? getDate(this.frequencyMetaData, this.dateFormat) : '');
}
options.create_symlink = options.create_symlink || false;
options.extension = options.extension || '';
this.filename = options.filename;
this.oldFile = null;
this.logfile = this.filename + (this.curDate ? '.' + this.curDate : '');
if (this.filename.match(/%DATE%/)) {
this.logfile = this.filename.replace(/%DATE%/g, (this.curDate ? this.curDate : getDate(null, this.dateFormat)));
}
if (this.fileSize) {
let lastLogFile = null;
let t_log = this.logfile;
if (auditLog && auditLog.files && auditLog.files instanceof Array && auditLog.files.length > 0) {
const lastEntry = auditLog.files[auditLog.files.length - 1].name;
if (lastEntry.match(t_log)) {
const lastCount = lastEntry.match(t_log + '\\.(\\d+)');
if (lastCount) {
t_log = lastEntry;
this.fileCount = lastCount[1];
}
}
}
if (this.fileCount == 0 && t_log == this.logfile) {
t_log += options.extension;
}
while (fs.existsSync(t_log)) {
lastLogFile = t_log;
this.fileCount++;
t_log = this.logfile + '.' + this.fileCount + options.extension;
}
if (lastLogFile) {
const lastLogFileStats = fs.statSync(lastLogFile);
if (lastLogFileStats.size < this.fileSize) {
t_log = lastLogFile;
this.fileCount--;
this.curSize = lastLogFileStats.size;
}
}
this.logfile = t_log;
} else {
this.logfile += options.extension;
}
if (this.verbose) {
console.log(new Date(), '[FileStreamRotator] Logging to: ', this.logfile);
}
if (this.verbose) {
console.log(new Date(), '[FileStreamRotator] Rotating file: ',
this.frequencyMetaData ? this.frequencyMetaData.type : '',
this.fileSize ? 'size: ' + this.fileSize : ''
);
}
this.file_options = options.file_options || { flags: 'a' };
this.on('createLog', (file) => {
try {
fs.lstatSync(file);
} catch (err) {
if (this.rotateStream && typeof this.rotateStream.end === 'function') {
this.rotateStream.end();
}
this.setWriteStream(file);
this.emit('new', file);
}
});
mkDirForFile(this.logfile);
this.setWriteStream(this.logfile);
this.on('close', () => {
if (logWatcher) {
logWatcher.close();
}
});
this.on('new', (newLog) => {
this.addLogToAudit(newLog, this.verbose);
if (options.create_symlink) {
createCurrentSymLink(newLog, options.symlink_name, this.verbose);
}
if (options.watch_log) {
this.emit('addWatcher', newLog);
}
});
let logWatcher;
this.on('addWatcher', (newLog) => {
if (logWatcher) {
logWatcher.close();
}
if (!options.watch_log) {
return;
}
logWatcher = createLogWatcher(newLog, this.verbose, (err, newLog) => {
this.emit('createLog', newLog);
});
});
process.nextTick(() => {
this.emit('new', this.logfile);
});
this.emit('new', this.logfile);
}
end(...args) {
this.rotateStream.end(...args);
}
write(str: string, encoding: BufferEncoding = 'utf-8') {
const newDate = this.frequencyMetaData ? getDate(this.frequencyMetaData, this.dateFormat) : this.curDate;
if (newDate != this.curDate || (this.fileSize && this.curSize > this.fileSize)) {
let newLogfile = this.filename + (this.curDate && this.frequencyMetaData ? '.' + newDate : '');
if (this.filename.match(/%DATE%/) && this.curDate) {
newLogfile = this.filename.replace(/%DATE%/g, newDate);
}
if (this.fileSize && this.curSize > this.fileSize) {
this.fileCount++;
newLogfile += '.' + this.fileCount + this.options.extension;
} else {
// reset file count
this.fileCount = 0;
newLogfile += this.options.extension;
}
this.curSize = 0;
if (this.verbose) {
console.log(new Date(), `[FileStreamRotator] Changing logs from ${this.logfile} to ${newLogfile}`);
}
this.curDate = newDate;
this.oldFile = this.logfile;
this.logfile = newLogfile;
// Thanks to @mattberther https://github.com/mattberther for raising it again.
if (this.options.end_stream === true) {
this.rotateStream.end();
} else {
this.rotateStream.destroy();
}
mkDirForFile(this.logfile);
this.setWriteStream(newLogfile);
this.emit('new', newLogfile);
this.emit('rotate', this.oldFile, newLogfile);
}
this.rotateStream.write(str, encoding);
// Handle length of double-byte characters
this.curSize += Buffer.byteLength(str, encoding);
}
addLogToAudit(newLog, verbose) {
this.auditLog = addLogToAudit(newLog, this.auditLog, this, verbose);
}
setWriteStream(logfile: string) {
this.rotateStream = fs.createWriteStream(logfile, this.file_options);
this.rotateStream.on('close', () => {
this.emit('close');
});
this.rotateStream.on('finish', () => {
this.emit('finish');
});
this.rotateStream.on('error', (err) => {
this.emit('error',err);
});
this.rotateStream.on('open', (fd) => {
this.emit('open',fd);
});
}
}
const DATE_FORMAT = 'YYYYMMDDHHmm';
/**
* Returns frequency metadata for minute/hour rotation
* @param type
* @param num
* @returns {*}
* @private
*/
function _checkNumAndType(type, num) {
if (typeof num == 'number') {
switch (type) {
case 'm':
if (num < 0 || num > 60) {
return false;
}
break;
case 'h':
if (num < 0 || num > 24) {
return false;
}
break;
}
return {type: type, digit: num};
}
}
/**
* Returns frequency metadata for defined frequency
* @param freqType
* @returns {*}
* @private
*/
function _checkDailyAndTest(freqType) {
switch (freqType) {
case 'custom':
case 'daily':
return {type: freqType, digit: undefined};
case 'test':
return {type: freqType, digit: 0};
}
return false;
}
/**
* Returns frequency metadata
* @param frequency
* @returns {*}
*/
export function getFrequency(frequency) {
const _f = frequency.toLowerCase().match(/^(\d+)([mh])$/);
if (_f) {
return _checkNumAndType(_f[2], parseInt(_f[1]));
}
const dailyOrTest = _checkDailyAndTest(frequency);
if (dailyOrTest) {
return dailyOrTest;
}
return false;
}
/**
* Returns a number based on the option string
* @param size
* @returns {Number}
*/
function parseFileSize(size) {
if (size && typeof size == 'string') {
const _s = size.toLowerCase().match(/^((?:0\.)?\d+)([kmg])$/);
if (_s) {
const s1 = parseInt(_s[1]);
switch(_s[2]) {
case 'k':
return s1 * 1024;
case 'm':
return s1 * 1024 * 1024;
case 'g':
return s1 * 1024 * 1024 * 1024;
}
}
}
return null;
}
const staticFrequency = ['daily', 'test', 'm', 'h', 'custom'];
/**
* Returns date string for a given format / date_format
* @param format
* @param date_format
* @returns {string}
*/
function getDate(format, date_format: string) {
date_format = date_format || DATE_FORMAT;
const currentMoment = dayjs();
if (format && staticFrequency.indexOf(format.type) !== -1) {
switch (format.type) {
case 'm':
{
const minute = Math.floor(currentMoment.minute() / format.digit) * format.digit;
return currentMoment.minute(minute).format(date_format);
}
case 'h':
{
const hour = Math.floor(currentMoment.hour() / format.digit) * format.digit;
return currentMoment.hour(hour).format(date_format);
}
case 'daily':
case 'custom':
case 'test':
return currentMoment.format(date_format);
}
}
return currentMoment.format(date_format);
}
/**
* Read audit json object from disk or return new object or null
* @param max_logs
* @param log_file
* @returns {Object} auditLogSettings
* @property {Object} auditLogSettings.keep
* @property {Boolean} auditLogSettings.keep.days
* @property {Number} auditLogSettings.keep.amount
* @property {String} auditLogSettings.auditLog
* @property {Array} auditLogSettings.files
* @property {String} auditLogSettings.hashType
*/
function setAuditLog(max_logs: number, audit_file: string, log_file: string) {
let _rtn = null;
if (max_logs) {
const use_days = max_logs.toString().substr(-1);
const _num = max_logs.toString().match(/^(\d+)/);
if (Number(_num[1]) > 0) {
const baseLog = path.dirname(log_file.replace(/%DATE%.+/, '_filename'));
try{
if (audit_file) {
const full_path = path.resolve(audit_file);
_rtn = JSON.parse(fs.readFileSync(full_path, { encoding: 'utf-8' }));
} else {
const full_path = path.resolve(baseLog + '/' + '.audit.json');
_rtn = JSON.parse(fs.readFileSync(full_path, { encoding: 'utf-8' }));
}
} catch(e) {
if (e.code !== 'ENOENT') {
return null;
}
_rtn = {
keep: {
days: false,
amount: Number(_num[1])
},
auditLog: audit_file || baseLog + '/.audit.json',
files: []
};
}
_rtn.keep = {
days: use_days === 'd',
amount: Number(_num[1])
};
}
}
return _rtn;
}
/**
* Write audit json object to disk
* @param {Object} audit
* @param {Object} audit.keep
* @param {Boolean} audit.keep.days
* @param {Number} audit.keep.amount
* @param {String} audit.auditLog
* @param {Array} audit.files
* @param {String} audit.hashType
* @param {Boolean} verbose
*/
function writeAuditLog(audit, verbose) {
try {
mkDirForFile(audit.auditLog);
fs.writeFileSync(audit.auditLog, JSON.stringify(audit,null,4));
} catch (e) {
if (verbose) {
console.error(new Date(), '[FileStreamRotator] Failed to store log audit at:', audit.auditLog, 'Error:', e);
}
}
}
/**
* Removes old log file
* @param file
* @param file.hash
* @param file.name
* @param file.date
* @param file.hashType
* @param {Boolean} verbose
*/
function removeFile(file, verbose) {
if (file.hash === crypto.createHash(file.hashType).update(file.name + 'LOG_FILE' + file.date).digest('hex')) {
try {
if (fs.existsSync(file.name)) {
fs.unlinkSync(file.name);
}
} catch(e) {
if (verbose) {
console.error(new Date(), '[FileStreamRotator] Could not remove old log file: ', file.name);
}
}
}
}
/**
* Create symbolic link to current log file
* @param {String} logfile
* @param {String} name Name to use for symbolic link
* @param {Boolean} verbose
*/
function createCurrentSymLink(logfile, name, verbose) {
const symLinkName = name || 'current.log';
const logPath = path.dirname(logfile);
const logfileName = path.basename(logfile);
const current = logPath + '/' + symLinkName;
try {
const stats = fs.lstatSync(current);
if (stats.isSymbolicLink()) {
fs.unlinkSync(current);
fs.symlinkSync(logfileName, current);
}
} catch (err) {
if (err && err.code === 'ENOENT') {
try {
fs.symlinkSync(logfileName, current);
} catch (e) {
if (verbose) {
console.error(new Date(), '[FileStreamRotator] Could not create symlink file: ', current, ' -> ', logfileName);
}
}
}
}
}
/**
*
* @param {String} logfile
* @param {Boolean} verbose
* @param {function} cb
*/
function createLogWatcher(logfile, verbose, cb) {
if (!logfile) return null;
// console.log("Creating log watcher")
try {
const stats = fs.lstatSync(logfile);
return fs.watch(logfile, (event,filename) => {
// console.log(Date(), event, filename)
if (event == 'rename') {
try {
const stats = fs.lstatSync(logfile);
// console.log('STATS:', stats);
} catch(err) {
// console.log("ERROR:", err);
cb(err,logfile);
}
}
});
} catch(err) {
if (verbose) {
console.log(new Date(), '[FileStreamRotator] Could not add watcher for ' + logfile);
}
}
}
/**
* Write audit json object to disk
* @param {String} logfile
* @param {Object} audit
* @param {Object} audit.keep
* @param {Boolean} audit.keep.days
* @param {Number} audit.keep.amount
* @param {String} audit.auditLog
* @param {String} audit.hashType
* @param {Array} audit.files
* @param {EventEmitter} stream
* @param {Boolean} verbose
*/
function addLogToAudit(logfile: string, audit: Audit, stream: EventEmitter, verbose: boolean) {
if (audit && audit.files) {
// Based on contribution by @nickbug - https://github.com/nickbug
const index = audit.files.findIndex((file) => {
return (file.name === logfile);
});
if (index !== -1) {
// nothing to do as entry already exists.
return audit;
}
const time = Date.now();
audit.files.push({
date: time,
name: logfile,
hash: crypto.createHash(audit.hashType).update(logfile + 'LOG_FILE' + time).digest('hex')
});
if (audit.keep.days) {
const oldestDate = moment().subtract(audit.keep.amount,'days').valueOf();
audit.files = audit.files.filter((file) => {
if (file.date > oldestDate) {
return true;
}
file.hashType = audit.hashType;
removeFile(file, verbose);
stream.emit('logRemoved', file);
return false;
});
} else {
const filesToKeep = audit.files.splice(-audit.keep.amount);
if (audit.files.length > 0) {
audit.files.filter((file) => {
file.hashType = audit.hashType;
removeFile(file, verbose);
stream.emit('logRemoved', file);
return false;
});
}
audit.files = filesToKeep;
}
writeAuditLog(audit, verbose);
}
return audit;
}
/**
* Check and make parent directory
* @param pathWithFile
*/
function mkDirForFile(pathWithFile) {
const _path = path.dirname(pathWithFile);
_path.split(path.sep).reduce(
(fullPath, folder) => {
fullPath += folder + path.sep;
if (!fs.existsSync(fullPath)) {
try {
fs.mkdirSync(fullPath);
} catch(e) {
if (e.code !== 'EEXIST') {
throw e;
}
}
}
return fullPath;
},
''
);
}