feature/nodejs_implementation #1

Merged
revengeday merged 11 commits from zeyus/hit-counter:feature/nodejs_implementation into main 2024-11-07 15:30:00 +00:00
Showing only changes of commit 51cd15aa4f - Show all commits

View file

@ -3,6 +3,7 @@ import sharp, { FormatEnum, Metadata, Sharp } from 'sharp';
import * as fs from 'fs'; import * as fs from 'fs';
import * as https from 'https'; import * as https from 'https';
// Define the image format map
const imageFormatMap: Record<string, keyof FormatEnum> = { const imageFormatMap: Record<string, keyof FormatEnum> = {
jpg: 'jpeg', jpg: 'jpeg',
jpeg: 'jpeg', jpeg: 'jpeg',
@ -12,6 +13,7 @@ const imageFormatMap: Record<string, keyof FormatEnum> = {
gif: 'gif' gif: 'gif'
}; };
// Define the MIME type map
const mimeTypeMap: Record<string, string> = { const mimeTypeMap: Record<string, string> = {
jpg: 'image/jpeg', jpg: 'image/jpeg',
jpeg: 'image/jpeg', jpeg: 'image/jpeg',
@ -21,22 +23,27 @@ const mimeTypeMap: Record<string, string> = {
gif: 'image/gif' gif: 'image/gif'
}; };
// Database result for hits
interface HitResult { interface HitResult {
hits: number; hits: number;
} }
// RGB color type
interface RGBColor { interface RGBColor {
r: number; r: number;
g: number; g: number;
b: number; b: number;
} }
// Allows use of base class and subclass as a valid type
export interface Type<T> extends Function { new (...args: any[]): T; } export interface Type<T> extends Function { new (...args: any[]): T; }
// Hit counter renderer interface
interface HitCounterRenderer { interface HitCounterRenderer {
render(): Promise<any>; render(): Promise<any>;
} }
// Hit counter configuration
interface HitCounterConfig { interface HitCounterConfig {
siteId: number; siteId: number;
// path to the database file // path to the database file
@ -63,6 +70,7 @@ interface HitCounterConfig {
drawFrame: boolean; // Whether to draw a frame around the counter drawFrame: boolean; // Whether to draw a frame around the counter
} }
// Default configuration for the hit counter
const defaultHitCounterConfig: HitCounterConfig = { const defaultHitCounterConfig: HitCounterConfig = {
siteId: 1, siteId: 1,
counterDB: './hits.db', counterDB: './hits.db',
@ -85,12 +93,29 @@ const defaultHitCounterConfig: HitCounterConfig = {
drawFrame: true, drawFrame: true,
}; };
/**
* Extract the file extension from a file path.
*
* @param {String} file
*
* @returns {String}
* @throws {Error} if the file extension is invalid
*/
function fileExt(file: string): string { function fileExt(file: string): string {
const ext = file.split('.').pop(); const ext = file.split('.').pop();
if (!ext) throw new Error('Invalid file extension'); if (!ext) throw new Error('Invalid file extension');
return ext.toLowerCase(); return ext.toLowerCase();
} }
/**
* Convert a file extension to a Sharp image format.
*
* @param {String} ext
*
* @returns {String}
* @throws {Error} if the file extension is not supported
*/
function extToFormat(ext: string): keyof FormatEnum { function extToFormat(ext: string): keyof FormatEnum {
const format = imageFormatMap[ext]; const format = imageFormatMap[ext];
if (!format) throw new Error('Unsupported file extension'); if (!format) throw new Error('Unsupported file extension');
@ -106,8 +131,10 @@ function extToFormat(ext: string): keyof FormatEnum {
* https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url * https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url
* *
* @param {String|URL} path * @param {String|URL} path
* The path to check.
* *
* @returns {Boolean} * @returns {Boolean}
* True if the path is a web URI, false otherwise.
*/ */
function isWebURI(path: string | URL): boolean { function isWebURI(path: string | URL): boolean {
try { try {
@ -118,30 +145,67 @@ function isWebURI(path: string | URL): boolean {
} }
} }
/**
* Base class for hit counter renderers.
*
* @class BaseHitCounterRenderer
* @implements {HitCounterRenderer}
*/
class BaseHitCounterRenderer implements HitCounterRenderer { class BaseHitCounterRenderer implements HitCounterRenderer {
protected counter: HitCounter; protected counter: HitCounter;
protected config: HitCounterConfig; protected config: HitCounterConfig;
/**
* Creates an instance of BaseHitCounterRenderer.
*
* @param {HitCounter} counter
* The hit counter instance.
* @param {HitCounterConfig} config
* The hit counter configuration.
*/
constructor(counter: HitCounter, config: HitCounterConfig) { constructor(counter: HitCounter, config: HitCounterConfig) {
this.counter = counter; this.counter = counter;
this.config = config; this.config = config;
} }
/**
* Render the hit counter.
*/
async render(): Promise<any> { async render(): Promise<any> {
throw new Error('Render not implemented'); throw new Error('Render not implemented');
} }
} }
/**
* Image hit counter renderer.
*
* @class ImageHitCounterRenderer
* @extends {BaseHitCounterRenderer}
*/
class ImageHitCounterRenderer extends BaseHitCounterRenderer { class ImageHitCounterRenderer extends BaseHitCounterRenderer {
private imageFormat: keyof FormatEnum; private imageFormat: keyof FormatEnum;
private baseImage: Buffer; private baseImage: Buffer;
private baseImageMetadata: Metadata; private baseImageMetadata: Metadata;
/**
* Creates an instance of ImageHitCounterRenderer.
*
* @param {HitCounter} counter
* The hit counter instance.
* @param {HitCounterConfig} config
* The hit counter configuration.
*/
constructor(counter: HitCounter, config: HitCounterConfig) { constructor(counter: HitCounter, config: HitCounterConfig) {
super(counter, config); super(counter, config);
this.imageFormat = extToFormat(fileExt(this.config.imageFile)); this.imageFormat = extToFormat(fileExt(this.config.imageFile));
} }
/**
* Download the background image.
*
* @returns {Promise<Buffer>}
* The downloaded image.
*/
private async downloadImage(): Promise<Buffer> { private async downloadImage(): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let buf = Buffer.alloc(0); let buf = Buffer.alloc(0);
@ -152,6 +216,15 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
}); });
} }
/**
* Load or cache the background image.
*
* @param {Boolean} [overwrite=false]
* Whether to overwrite the cached background image.
*
* @returns {Promise<Sharp>}
* The loaded or cached background image.
*/
private async loadOrCacheBG(overwrite = false): Promise<Sharp> { private async loadOrCacheBG(overwrite = false): Promise<Sharp> {
if (fs.existsSync(this.config.backgroundCacheFile) && !overwrite) { if (fs.existsSync(this.config.backgroundCacheFile) && !overwrite) {
return sharp(this.config.backgroundCacheFile); return sharp(this.config.backgroundCacheFile);
@ -166,6 +239,12 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
return image; return image;
} }
/**
* Get the base image.
*
* @returns {Promise<Buffer>}
* The base image.
*/
private async getBaseImage(): Promise<Buffer> { private async getBaseImage(): Promise<Buffer> {
if (typeof this.baseImage !== 'undefined') { if (typeof this.baseImage !== 'undefined') {
return this.baseImage; return this.baseImage;
@ -181,6 +260,17 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
return this.baseImage; return this.baseImage;
} }
/**
* Create an SVG overlay.
*
* @param {Number} width
* The width of the overlay.
* @param {Number} height
* The height of the overlay.
*
* @returns {Buffer}
* The SVG overlay.
*/
private createSvgOverlay(width: number, height: number): Buffer { private createSvgOverlay(width: number, height: number): Buffer {
const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, frameColorRGB, drawFrame } = this.config; const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, frameColorRGB, drawFrame } = this.config;
const frame = drawFrame const frame = drawFrame
@ -198,15 +288,34 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
return Buffer.from(svg); return Buffer.from(svg);
} }
private createSVGNumberOverlay(hits: number, imgWidth: number, imgHeight: number): Buffer { /**
* Create an SVG number overlay.
*
* @param {Number} hits
* The number of hits.
* @param {Number} width
* The width of the image.
* @param {Number} height
* The height of the image.
*
* @returns {Buffer}
* The SVG number overlay.
*/
private createSVGNumberOverlay(hits: number, width: number, height: number): Buffer {
const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config; const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config;
const svg = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}"> <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<text x="${numberPositionX}" y="${numberPositionY}" text-anchor="end" font-family="${fontFace}" font-size="${fontSize}" fill="rgb(${numberColorRGB.r},${numberColorRGB.g},${numberColorRGB.b})">${hits}</text> <text x="${numberPositionX}" y="${numberPositionY}" text-anchor="end" font-family="${fontFace}" font-size="${fontSize}" fill="rgb(${numberColorRGB.r},${numberColorRGB.g},${numberColorRGB.b})">${hits}</text>
</svg>`; </svg>`;
return Buffer.from(svg); return Buffer.from(svg);
} }
/**
* Render the hit counter.
*
* @returns {Promise<Buffer>}
* The rendered hit counter.
*/
async render(): Promise<Buffer> { async render(): Promise<Buffer> {
const hits = await this.counter.getHits(); const hits = await this.counter.getHits();
return await sharp(await this.getBaseImage()) return await sharp(await this.getBaseImage())
@ -222,6 +331,11 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
} }
} }
/**
* Hit counter class.
*
* @class HitCounter
*/
class HitCounter { class HitCounter {
private db: DB; private db: DB;
private renderer: BaseHitCounterRenderer; private renderer: BaseHitCounterRenderer;
@ -229,6 +343,14 @@ class HitCounter {
private mimeType: string; private mimeType: string;
private config: HitCounterConfig; private config: HitCounterConfig;
/**
* Creates an instance of HitCounter.
*
* @param {Type<BaseHitCounterRenderer>} renderer
* The hit counter renderer.
* @param {HitCounterConfig} [config=defaultHitCounterConfig]
* The hit counter configuration.
*/
constructor(renderer: Type<BaseHitCounterRenderer>, config: HitCounterConfig = defaultHitCounterConfig) { constructor(renderer: Type<BaseHitCounterRenderer>, config: HitCounterConfig = defaultHitCounterConfig) {
config = { ...defaultHitCounterConfig, ...config }; config = { ...defaultHitCounterConfig, ...config };
this.config = config; this.config = config;
@ -238,6 +360,15 @@ class HitCounter {
this.preparedStatements = this.prepareStatements(); this.preparedStatements = this.prepareStatements();
} }
/**
* Initialize the database connection.
*
* @param {String} filePath
* The path to the database file.
*
* @returns {DB}
* The database instance.
*/
private initDB(filePath: string): DB { private initDB(filePath: string): DB {
const db = new Database(filePath, {fileMustExist: false}); const db = new Database(filePath, {fileMustExist: false});
db.pragma('locking_mode = EXCLUSIVE'); db.pragma('locking_mode = EXCLUSIVE');
@ -246,6 +377,12 @@ class HitCounter {
return db; return db;
} }
/**
* Prepare the SQL statements.
*
* @returns {Record<string, Statement>}
* The prepared statements.
*/
private prepareStatements(): Record<string, Statement> { private prepareStatements(): Record<string, Statement> {
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS hit_counter ( CREATE TABLE IF NOT EXISTS hit_counter (
@ -266,27 +403,54 @@ class HitCounter {
}; };
} }
/**
* Get the number of hits.
*
* @returns {Promise<Number>}
* The number of hits
*/
async getHits(): Promise<number> { async getHits(): Promise<number> {
const result = this.preparedStatements.get.get(this.config.siteId) as HitResult; const result = this.preparedStatements.get.get(this.config.siteId) as HitResult;
return result.hits; return result.hits;
} }
/**
* Increment the hit counter.
*/
async increment(): Promise<void> { async increment(): Promise<void> {
this.preparedStatements.increment.run(this.config.siteId); this.preparedStatements.increment.run(this.config.siteId);
} }
/**
* Reset the hit counter
*/
async reset(): Promise<void> { async reset(): Promise<void> {
this.preparedStatements.reset.run(this.config.siteId); this.preparedStatements.reset.run(this.config.siteId);
} }
/**
* Render the hit counter.
*
* @returns {Promise<any>}
* The rendered hit counter.
*/
async render(): Promise<any> { async render(): Promise<any> {
return this.renderer.render(); return this.renderer.render();
} }
/**
* Get the MIME type of the hit counter image.
*
* @returns {String}
* The MIME type.
*/
getMimeType(): string { getMimeType(): string {
return this.mimeType; return this.mimeType;
} }
/**
* Destructor.
*/
finalize(): void { finalize(): void {
this.db.close(); this.db.close();
} }