forked from revengeday/hit-counter
Comments!
This commit is contained in:
parent
0c744f0883
commit
51cd15aa4f
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 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();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue