import { type Database as DB } from 'better-sqlite3'; import { type Statement } from 'better-sqlite3'; import Database from 'better-sqlite3'; // import sharp for images import sharp, { FormatEnum, AvailableFormatInfo, Sharp, Metadata, TextAlign } from 'sharp'; import * as fs from 'fs'; import * as https from 'https'; // Map file extensions to sharp formats const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo } = { jpg: 'jpeg', jpeg: 'jpeg', png: 'png', webp: 'webp', tiff: 'tiff', gif: 'gif' }; // no need for third party library const mimeTypeMap: { [key: string]: string } = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', tiff: 'image/tiff', gif: 'image/gif' }; interface HitResult { hits: number; } interface RGBColor { r: number; g: number; b: number; } export interface Type extends Function { new (...args: any[]): T; } interface HitCounterRenderer { render(): Promise; } function fileExt(file: string): string { const fileExt = file.split('.').pop(); if (!fileExt) { throw new Error('Invalid file extension'); } return fileExt.toLowerCase(); } function extToFormat(ext: string): keyof FormatEnum | AvailableFormatInfo { const format = imageFormatMap[ext]; if (!format) { throw new Error('Unsupported file extension'); } return format; } /** * Determine whether the given `path` is a http(s) URL. This method * accepts a string value and a URL instance. * * inspiration: * Marcus Pöhls * https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url * * @param {String|URL} path * * @returns {Boolean} */ function isWebURI(path) { // `path` is neither a string nor a URL -> early return if (typeof path !== 'string' && !(path instanceof URL)) { return false } try { const url = new URL(path) return url.protocol === 'http:' || url.protocol === 'https:' } catch (error) { return false } } class BaseHitCounterRenderer implements HitCounterRenderer { protected counter: HitCounter; protected config: HitCounterConfig; constructor(counter: HitCounter, config: HitCounterConfig) { this.counter = counter; this.config = config; } async render(): Promise { // render the counter throw new Error('Not implemented'); } } class ImageHitCounterRenderer extends BaseHitCounterRenderer { private imageType: string; private imageFormat: keyof FormatEnum | AvailableFormatInfo; private baseImage: Buffer; private baseImageMetadata: Metadata; constructor(counter: HitCounter, config: HitCounterConfig) { super(counter, config); this.imageType = fileExt(this.config.imageFile); this.imageFormat = extToFormat(this.imageType); } private async downloadImage(): Promise { return new Promise((resolve, reject) => { let buf = Buffer.alloc(0); const request = https.get(this.config.backgroundImageUrl, (response) => { response.on('data', (chunk) => { buf = Buffer.concat([buf, chunk]); }); response.on('end', () => { resolve(buf); }); }); request.on('error', (err) => { fs.unlink(this.config.backgroundCacheFile, () => { reject(err); }); }); }); } private async cachedBackgroundImage(overWrite: boolean = false): Promise { // cache the image const dest = this.config.backgroundCacheFile; if (fs.existsSync(dest)) { if (overWrite) { fs.unlinkSync(dest); } else { return sharp(dest); } } // store the image in the cache const ext = fileExt(this.config.backgroundImageUrl); const format = extToFormat(ext); if (isWebURI(this.config.backgroundImageUrl)) { const buf = await this.downloadImage(); const image = sharp(buf).toFormat(format); await image.toFile(dest); return image; } else { if (fs.existsSync(this.config.backgroundImageUrl)) { const image = sharp(this.config.backgroundImageUrl).toFormat(format); await image.toFile(dest); return image; } else { throw new Error('Background image does not exist'); } } } async prepareBaseImage(): Promise { if (typeof this.baseImage !== 'undefined') { return; } const bgImage = await this.cachedBackgroundImage(); const bgInfo = await bgImage.metadata(); const imgWidth = bgInfo.width; const imgHeight = bgInfo.height; const imgDensity = bgInfo.density; if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined' || typeof imgDensity === 'undefined') { throw new Error('Invalid image dimensions'); } let svgFrame = ''; if (this.config.drawFrame) { svgFrame = ``; } const svgOverlay = ` ${this.config.customText} ${this.config.secondaryText} ${svgFrame} `; const overlay = Buffer.from(svgOverlay); const composite = bgImage.composite([ { input: overlay, top: 0, left: 0 } ]); const preparedImg = composite.toFormat(this.imageFormat).withMetadata(); this.baseImageMetadata = await preparedImg.metadata(); this.baseImage = await preparedImg.toBuffer(); } async render(): Promise { const hits = await this.counter.getHits(); const numberX = this.config.numberPositionX; const numberY = this.config.numberPositionY; await this.prepareBaseImage(); const imgWidth = this.baseImageMetadata.width; const imgHeight = this.baseImageMetadata.height; if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined') { throw new Error('Invalid image dimensions'); } const svgNumber = ` ${hits} `; const numberOverlay = Buffer.from(svgNumber); const composite = sharp(this.baseImage).composite([ { input: numberOverlay, top: 0, left: 0 } ]); return composite.toBuffer(); } } interface HitCounterConfig { siteId: number; // path to the database file counterDB: string; // Background image URL for the counter display backgroundImageUrl: string; // local image path imageFile: string; backgroundCacheFile: string; // Text settings to be displayed on the image customText: string; // Main headline text secondaryText: string; // Secondary descriptive text textPositionX: number; // X position of the text textPositionY: number; // Y position of the text numberPositionX: number; // X position of the number numberPositionY: number; // Y position of the number fontSize: number; // Font size fontFace: string; // Font face borderWidth: number; // Border width textColorRGB: RGBColor; // Text color secondaryTextColorRGB: RGBColor; // Secondary text color numberColorRGB: RGBColor; // Number color frameColorRGB: RGBColor; // Frame color drawFrame: boolean; // Whether to draw a frame around the counter renderer: Type; // Renderer class for the counter } // default configuration const defaultHitCounterConfig: HitCounterConfig = { siteId: 1, counterDB: './hits.db', backgroundImageUrl: 'https://datakra.sh/assets/example_counter.jpg', imageFile: './example_counter.png', backgroundCacheFile: './example_counter_bg.png', customText: 'Hit Counter!', secondaryText: 'Super cyber, super cool!', textPositionX: 5, textPositionY: 11, numberPositionX: 170, numberPositionY: 18, fontSize: 10, fontFace: 'fixed', borderWidth: 3, textColorRGB: { r: 253, g: 252, b: 1 }, secondaryTextColorRGB: { r: 0, g: 255, b: 0 }, numberColorRGB: { r: 255, g: 255, b: 255 }, frameColorRGB: { r: 255, g: 0, b: 0 }, drawFrame: true, renderer: ImageHitCounterRenderer }; class HitCounter { private db: DB; private renderer: BaseHitCounterRenderer; private config: HitCounterConfig; private preparedGet: Statement; private preparedIncrement: Statement; private preparedReset: Statement; private mimeType: string; constructor(renderer: Type, config?: HitCounterConfig) { this.config = { ...defaultHitCounterConfig, ...config }; this.renderer = new renderer(this, this.config); this.openDB(); this.setupDB(); this.setupStatements(); this.mimeType = mimeTypeMap[fileExt(this.config.imageFile)]; } private openDB(): void { const db = new Database(this.config.counterDB, {fileMustExist: false}); db.pragma('locking_mode = EXCLUSIVE'); db.pragma('journal_mode = WAL'); db.pragma('synchronous = OFF'); this.db = db; } private setupDB(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS hit_counter ( id INTEGER PRIMARY KEY, hits INTEGER NOT NULL ); `); this.db.exec(` INSERT OR IGNORE INTO hit_counter (id, hits) VALUES (?, ${this.config.siteId}) `); } private closeDB(): void { this.db.close(); } private setupStatements(): void { this.preparedGet = this.db.prepare(` SELECT hits FROM hit_counter WHERE id = ? `); this.preparedIncrement = this.db.prepare(` UPDATE hit_counter SET hits = hits + 1 WHERE id = ? `); this.preparedReset = this.db.prepare(` UPDATE hit_counter SET hits = 0 WHERE id = ? `); } async getHits(): Promise { const result = this.preparedGet.get(this.config.siteId) as HitResult; return result.hits; } async increment(): Promise { this.preparedIncrement.run(this.config.siteId); } async reset(): Promise { this.preparedReset.run(this.config.siteId); } async render(): Promise { return this.renderer.render(); } getMimeType(): string { return this.mimeType; } // destructor destroy(): void { this.closeDB(); } } export { HitCounter, HitCounterConfig, HitCounterRenderer, BaseHitCounterRenderer, ImageHitCounterRenderer }; export default HitCounter;