import e, { Request, Response, NextFunction } from 'express'; 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 } from 'sharp'; import * as fs from 'fs'; // 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' }; 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; } 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; constructor(counter: HitCounter, config: HitCounterConfig) { super(counter, config); const fileExt = this.config.imageFile.split('.').pop(); if (!fileExt) { throw new Error('Invalid file extension'); } this.imageType = fileExt; this.imageFormat = imageFormatMap[fileExt.toLowerCase()]; if (!this.imageFormat) { throw new Error('Unsupported file extension'); } } 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 fileExt = dest.split('.').pop(); if (!fileExt) { throw new Error('Invalid file extension'); } const format = imageFormatMap[fileExt.toLowerCase()]; if (!format) { throw new Error('Unsupported file extension'); } const image = sharp(this.config.backgroundImageUrl).toFormat(format); await image.toFile(dest); return image; } async render(): Promise { const hits = await this.counter.getHits(); const bgImage = await this.cachedBackgroundImage(); const bgInfo = await bgImage.metadata(); const text = this.config.customText; const secondaryText = this.config.secondaryText; const textColor = this.config.textColorRGB; const secondaryTextColor = this.config.secondaryTextColorRGB; const numberColor = this.config.numberColorRGB; const frameColor = this.config.frameColorRGB; const drawFrame = this.config.drawFrame; const textX = this.config.textPositionX; const textY = this.config.textPositionY; const numberX = this.config.numberPositionX; const numberY = this.config.numberPositionY; const svgFrame = ` `; const svgOverlay = ` ${text} ${secondaryText} ${hits} `; const overlay = sharp(Buffer.from(svgOverlay)); if (drawFrame) { const frame = sharp(Buffer.from(svgFrame)); const composite = bgImage.composite([ { input: await overlay.toBuffer(), gravity: 'northwest' }, { input: await frame.toBuffer(), gravity: 'northwest' } ]); return composite; } const composite = bgImage.composite([ { input: await overlay.toBuffer(), gravity: 'northwest' } ]); return composite; } } 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 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: 5, numberPositionX: 5, numberPositionY: 15, 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: 238, 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; constructor(renderer: Type, config?: HitCounterConfig) { this.config = { ...defaultHitCounterConfig, ...config }; this.renderer = new renderer(this, this.config); this.openDB(); this.setupDB(); this.setupStatements(); } 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); } // destructor destroy(): void { this.closeDB(); } } export { HitCounter, HitCounterConfig, HitCounterRenderer, BaseHitCounterRenderer, ImageHitCounterRenderer }; export default HitCounter;