hit-counter/node/src/hit-counter.ts

288 lines
8.7 KiB
TypeScript
Raw Normal View History

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<T> extends Function { new (...args: any[]): T; }
interface HitCounterRenderer {
render(): Promise<any>;
}
class BaseHitCounterRenderer implements HitCounterRenderer {
protected counter: HitCounter;
protected config: HitCounterConfig;
constructor(counter: HitCounter, config: HitCounterConfig) {
this.counter = counter;
this.config = config;
}
async render(): Promise<any> {
// 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<Sharp> {
// 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<Sharp> {
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 = `
<svg width="${bgInfo.width}" height="${bgInfo.height}" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="${bgInfo.width}" height="${bgInfo.height}" stroke="rgb(${frameColor.r},${frameColor.g},${frameColor.b})" stroke-width="2" fill="none"/>
</svg>
`;
const svgOverlay = `
<svg width="${bgInfo.width}" height="${bgInfo.height}" xmlns="http://www.w3.org/2000/svg">
<text x="${textX}" y="${textY}" fill="rgb(${textColor.r},${textColor.g},${textColor.b})" font-size="12">${text}</text>
<text x="${textX}" y="${textY + 15}" fill="rgb(${secondaryTextColor.r},${secondaryTextColor.g},${secondaryTextColor.b})" font-size="12">${secondaryText}</text>
<text x="${numberX}" y="${numberY}" fill="rgb(${numberColor.r},${numberColor.g},${numberColor.b})" font-size="12">${hits}</text>
</svg>
`;
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<BaseHitCounterRenderer>; // 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<BaseHitCounterRenderer>, 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<number> {
const result = this.preparedGet.get(this.config.siteId) as HitResult;
return result.hits;
}
async increment(): Promise<void> {
this.preparedIncrement.run(this.config.siteId);
}
async reset(): Promise<void> {
this.preparedReset.run(this.config.siteId);
}
// destructor
destroy(): void {
this.closeDB();
}
}
export {
HitCounter,
HitCounterConfig,
HitCounterRenderer,
BaseHitCounterRenderer,
ImageHitCounterRenderer
};
export default HitCounter;