feature/nodejs_implementation #1
1 changed files with 166 additions and 2 deletions
|
@ -3,6 +3,7 @@ import sharp, { FormatEnum, Metadata, Sharp } from 'sharp';
|
|||
import * as fs from 'fs';
|
||||
import * as https from 'https';
|
||||
|
||||
// Define the image format map
|
||||
const imageFormatMap: Record<string, keyof FormatEnum> = {
|
||||
jpg: 'jpeg',
|
||||
jpeg: 'jpeg',
|
||||
|
@ -12,6 +13,7 @@ const imageFormatMap: Record<string, keyof FormatEnum> = {
|
|||
gif: 'gif'
|
||||
};
|
||||
|
||||
// Define the MIME type map
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
|
@ -21,22 +23,27 @@ const mimeTypeMap: Record<string, string> = {
|
|||
gif: 'image/gif'
|
||||
};
|
||||
|
||||
// Database result for hits
|
||||
interface HitResult {
|
||||
hits: number;
|
||||
}
|
||||
|
||||
// RGB color type
|
||||
interface RGBColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
// Allows use of base class and subclass as a valid type
|
||||
export interface Type<T> extends Function { new (...args: any[]): T; }
|
||||
|
||||
// Hit counter renderer interface
|
||||
interface HitCounterRenderer {
|
||||
render(): Promise<any>;
|
||||
}
|
||||
|
||||
// Hit counter configuration
|
||||
interface HitCounterConfig {
|
||||
siteId: number;
|
||||
// path to the database file
|
||||
|
@ -63,6 +70,7 @@ interface HitCounterConfig {
|
|||
drawFrame: boolean; // Whether to draw a frame around the counter
|
||||
}
|
||||
|
||||
// Default configuration for the hit counter
|
||||
const defaultHitCounterConfig: HitCounterConfig = {
|
||||
siteId: 1,
|
||||
counterDB: './hits.db',
|
||||
|
@ -85,12 +93,29 @@ const defaultHitCounterConfig: HitCounterConfig = {
|
|||
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 {
|
||||
const ext = file.split('.').pop();
|
||||
if (!ext) throw new Error('Invalid file extension');
|
||||
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 {
|
||||
const format = imageFormatMap[ext];
|
||||
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
|
||||
*
|
||||
* @param {String|URL} path
|
||||
* The path to check.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
* True if the path is a web URI, false otherwise.
|
||||
*/
|
||||
function isWebURI(path: string | URL): boolean {
|
||||
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 {
|
||||
protected counter: HitCounter;
|
||||
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) {
|
||||
this.counter = counter;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hit counter.
|
||||
*/
|
||||
async render(): Promise<any> {
|
||||
throw new Error('Render not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image hit counter renderer.
|
||||
*
|
||||
* @class ImageHitCounterRenderer
|
||||
* @extends {BaseHitCounterRenderer}
|
||||
*/
|
||||
class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
||||
private imageFormat: keyof FormatEnum;
|
||||
private baseImage: Buffer;
|
||||
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) {
|
||||
super(counter, config);
|
||||
this.imageFormat = extToFormat(fileExt(this.config.imageFile));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the background image.
|
||||
*
|
||||
* @returns {Promise<Buffer>}
|
||||
* The downloaded image.
|
||||
*/
|
||||
private async downloadImage(): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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> {
|
||||
if (fs.existsSync(this.config.backgroundCacheFile) && !overwrite) {
|
||||
return sharp(this.config.backgroundCacheFile);
|
||||
|
@ -166,6 +239,12 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
|||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base image.
|
||||
*
|
||||
* @returns {Promise<Buffer>}
|
||||
* The base image.
|
||||
*/
|
||||
private async getBaseImage(): Promise<Buffer> {
|
||||
if (typeof this.baseImage !== 'undefined') {
|
||||
return this.baseImage;
|
||||
|
@ -181,6 +260,17 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
|||
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 {
|
||||
const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, frameColorRGB, drawFrame } = this.config;
|
||||
const frame = drawFrame
|
||||
|
@ -198,15 +288,34 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
|||
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 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>
|
||||
</svg>`;
|
||||
return Buffer.from(svg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hit counter.
|
||||
*
|
||||
* @returns {Promise<Buffer>}
|
||||
* The rendered hit counter.
|
||||
*/
|
||||
async render(): Promise<Buffer> {
|
||||
const hits = await this.counter.getHits();
|
||||
return await sharp(await this.getBaseImage())
|
||||
|
@ -222,6 +331,11 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit counter class.
|
||||
*
|
||||
* @class HitCounter
|
||||
*/
|
||||
class HitCounter {
|
||||
private db: DB;
|
||||
private renderer: BaseHitCounterRenderer;
|
||||
|
@ -229,6 +343,14 @@ class HitCounter {
|
|||
private mimeType: string;
|
||||
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) {
|
||||
config = { ...defaultHitCounterConfig, ...config };
|
||||
this.config = config;
|
||||
|
@ -238,6 +360,15 @@ class HitCounter {
|
|||
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 {
|
||||
const db = new Database(filePath, {fileMustExist: false});
|
||||
db.pragma('locking_mode = EXCLUSIVE');
|
||||
|
@ -246,6 +377,12 @@ class HitCounter {
|
|||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the SQL statements.
|
||||
*
|
||||
* @returns {Record<string, Statement>}
|
||||
* The prepared statements.
|
||||
*/
|
||||
private prepareStatements(): Record<string, Statement> {
|
||||
this.db.exec(`
|
||||
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> {
|
||||
const result = this.preparedStatements.get.get(this.config.siteId) as HitResult;
|
||||
return result.hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the hit counter.
|
||||
*/
|
||||
async increment(): Promise<void> {
|
||||
this.preparedStatements.increment.run(this.config.siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the hit counter
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
this.preparedStatements.reset.run(this.config.siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hit counter.
|
||||
*
|
||||
* @returns {Promise<any>}
|
||||
* The rendered hit counter.
|
||||
*/
|
||||
async render(): Promise<any> {
|
||||
return this.renderer.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MIME type of the hit counter image.
|
||||
*
|
||||
* @returns {String}
|
||||
* The MIME type.
|
||||
*/
|
||||
getMimeType(): string {
|
||||
return this.mimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor.
|
||||
*/
|
||||
finalize(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue