Made it a bit nicer, fixed a bug with the initial hit count.

This commit is contained in:
zeyus 2024-11-07 13:35:00 +01:00
parent b6f83af72e
commit 0c744f0883
Signed by untrusted user: zeyus
GPG key ID: A836639BA719C614
2 changed files with 172 additions and 266 deletions

View file

@ -92,6 +92,8 @@ interface HitCounterConfig {
```ts
import { HitCounter, ImageHitCounterRenderer } from "./hit-counter";
// default config
defaultHitCounterConfig = {
siteId: 1,
counterDB: './hits.db',
@ -115,7 +117,9 @@ defaultHitCounterConfig = {
renderer: ImageHitCounterRenderer
};
const hitCounter = new HitCounter(ImageHitCounterRenderer, defaultHitCounterConfig);
// the default config will be used, but you can pass a custom config object as
// the second argument to the HitCounter constructor
const hitCounter = new HitCounter(ImageHitCounterRenderer);
// increment the hit counter
hitCounter.increment(); // async function

View file

@ -1,13 +1,9 @@
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, Metadata, TextAlign } from 'sharp';
import Database, { Database as DB, Statement } from 'better-sqlite3';
import sharp, { FormatEnum, Metadata, Sharp } from 'sharp';
import * as fs from 'fs';
import * as https from 'https';
// Map file extensions to sharp formats
const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo } = {
const imageFormatMap: Record<string, keyof FormatEnum> = {
jpg: 'jpeg',
jpeg: 'jpeg',
png: 'png',
@ -16,8 +12,7 @@ const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo }
gif: 'gif'
};
// no need for third party library
const mimeTypeMap: { [key: string]: string } = {
const mimeTypeMap: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
@ -38,214 +33,10 @@ interface RGBColor {
export interface Type<T> extends Function { new (...args: any[]): T; }
interface HitCounterRenderer {
render(): Promise<any>;
}
function fileExt(file: string): string {
const fileExt = file.split('.').pop();
if (!fileExt) {
throw new Error('Invalid file extension');
}
return fileExt.toLowerCase();
}
function extToFormat(ext: string): keyof FormatEnum | AvailableFormatInfo {
const format = imageFormatMap[ext];
if (!format) {
throw new Error('Unsupported file extension');
}
return format;
}
/**
* Determine whether the given `path` is a http(s) URL. This method
* accepts a string value and a URL instance.
*
* inspiration:
* Marcus Pöhls
* https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url
*
* @param {String|URL} path
*
* @returns {Boolean}
*/
function isWebURI(path) {
// `path` is neither a string nor a URL -> early return
if (typeof path !== 'string' && !(path instanceof URL)) {
return false
}
try {
const url = new URL(path)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch (error) {
return false
}
}
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;
private baseImage: Buffer;
private baseImageMetadata: Metadata;
constructor(counter: HitCounter, config: HitCounterConfig) {
super(counter, config);
this.imageType = fileExt(this.config.imageFile);
this.imageFormat = extToFormat(this.imageType);
}
private async downloadImage(): Promise<Buffer> {
return new Promise((resolve, reject) => {
let buf = Buffer.alloc(0);
const request = https.get(this.config.backgroundImageUrl, (response) => {
response.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
});
response.on('end', () => {
resolve(buf);
});
});
request.on('error', (err) => {
fs.unlink(this.config.backgroundCacheFile, () => {
reject(err);
});
});
});
}
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 ext = fileExt(this.config.backgroundImageUrl);
const format = extToFormat(ext);
if (isWebURI(this.config.backgroundImageUrl)) {
const buf = await this.downloadImage();
const image = sharp(buf).toFormat(format);
await image.toFile(dest);
return image;
} else {
if (fs.existsSync(this.config.backgroundImageUrl)) {
const image = sharp(this.config.backgroundImageUrl).toFormat(format);
await image.toFile(dest);
return image;
} else {
throw new Error('Background image does not exist');
}
}
}
async prepareBaseImage(): Promise<void> {
if (typeof this.baseImage !== 'undefined') {
return;
}
const bgImage = await this.cachedBackgroundImage();
const bgInfo = await bgImage.metadata();
const imgWidth = bgInfo.width;
const imgHeight = bgInfo.height;
const imgDensity = bgInfo.density;
if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined' || typeof imgDensity === 'undefined') {
throw new Error('Invalid image dimensions');
}
let svgFrame = '';
if (this.config.drawFrame) {
svgFrame = `<rect x="0" y="0" width="100%" height="100%" fill="none" stroke-alignment="inner" stroke="rgb(${this.config.frameColorRGB.r},${this.config.frameColorRGB.g},${this.config.frameColorRGB.b})" stroke-width="${this.config.borderWidth}" />`;
}
const svgOverlay = `
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
<text y="${this.config.textPositionY}" font-family="${this.config.fontFace}" font-size="${this.config.fontSize}">
<tspan x="${this.config.textPositionX}" dy="0" fill="rgb(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})">${this.config.customText}</tspan>
<tspan x="${this.config.textPositionX}" dy="1.2em" fill="rgb(${this.config.secondaryTextColorRGB.r},${this.config.secondaryTextColorRGB.g},${this.config.secondaryTextColorRGB.b})">${this.config.secondaryText}</tspan>
</text>
${svgFrame}
</svg>
`;
const overlay = Buffer.from(svgOverlay);
const composite = bgImage.composite([
{
input: overlay,
top: 0,
left: 0
}
]);
const preparedImg = composite.toFormat(this.imageFormat).withMetadata();
this.baseImageMetadata = await preparedImg.metadata();
this.baseImage = await preparedImg.toBuffer();
}
async render(): Promise<Buffer> {
const hits = await this.counter.getHits();
const numberX = this.config.numberPositionX;
const numberY = this.config.numberPositionY;
await this.prepareBaseImage();
const imgWidth = this.baseImageMetadata.width;
const imgHeight = this.baseImageMetadata.height;
if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined') {
throw new Error('Invalid image dimensions');
}
const svgNumber = `
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
<text x="${numberX}" y="${numberY}" font-family="${this.config.fontFace}" font-size="${this.config.fontSize}" text-anchor="end" fill="rgb(${this.config.numberColorRGB.r},${this.config.numberColorRGB.g},${this.config.numberColorRGB.b})">${hits}</text>
</svg>
`;
const numberOverlay = Buffer.from(svgNumber);
const composite = sharp(this.baseImage).composite([
{
input: numberOverlay,
top: 0,
left: 0
}
]);
return composite.toBuffer();
}
}
interface HitCounterConfig {
siteId: number;
// path to the database file
@ -272,9 +63,6 @@ interface HitCounterConfig {
drawFrame: boolean; // Whether to draw a frame around the counter
}
// default configuration
const defaultHitCounterConfig: HitCounterConfig = {
siteId: 1,
counterDB: './hits.db',
@ -297,78 +85,198 @@ const defaultHitCounterConfig: HitCounterConfig = {
drawFrame: true,
};
function fileExt(file: string): string {
const ext = file.split('.').pop();
if (!ext) throw new Error('Invalid file extension');
return ext.toLowerCase();
}
function extToFormat(ext: string): keyof FormatEnum {
const format = imageFormatMap[ext];
if (!format) throw new Error('Unsupported file extension');
return format;
}
/**
* Determine whether the given `path` is a http(s) URL. This method
* accepts a string value and a URL instance.
*
* inspiration:
* Marcus Pöhls
* https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url
*
* @param {String|URL} path
*
* @returns {Boolean}
*/
function isWebURI(path: string | URL): boolean {
try {
const url = new URL(path.toString());
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
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> {
throw new Error('Render not implemented');
}
}
class ImageHitCounterRenderer extends BaseHitCounterRenderer {
private imageFormat: keyof FormatEnum;
private baseImage: Buffer;
private baseImageMetadata: Metadata;
constructor(counter: HitCounter, config: HitCounterConfig) {
super(counter, config);
this.imageFormat = extToFormat(fileExt(this.config.imageFile));
}
private async downloadImage(): Promise<Buffer> {
return new Promise((resolve, reject) => {
let buf = Buffer.alloc(0);
https.get(this.config.backgroundImageUrl, (response) => {
response.on('data', (chunk) => (buf = Buffer.concat([buf, chunk])));
response.on('end', () => resolve(buf));
}).on('error', reject);
});
}
private async loadOrCacheBG(overwrite = false): Promise<Sharp> {
if (fs.existsSync(this.config.backgroundCacheFile) && !overwrite) {
return sharp(this.config.backgroundCacheFile);
}
const imageBuffer = isWebURI(this.config.backgroundImageUrl)
? await this.downloadImage()
: fs.readFileSync(this.config.backgroundImageUrl);
const image = sharp(imageBuffer).toFormat(this.imageFormat);
await image.toFile(this.config.backgroundCacheFile);
return image;
}
private async getBaseImage(): Promise<Buffer> {
if (typeof this.baseImage !== 'undefined') {
return this.baseImage;
}
const bgImage = await this.loadOrCacheBG();
const bgMetadata = await bgImage.metadata();
const svgOverlay = this.createSvgOverlay(bgMetadata.width!, bgMetadata.height!);
const compositeImage = bgImage.composite([{ input: svgOverlay, top: 0, left: 0, density: bgMetadata.density! }]).withMetadata();
this.baseImage = await compositeImage.toBuffer();
this.baseImageMetadata = await compositeImage.metadata();
return this.baseImage;
}
private createSvgOverlay(width: number, height: number): Buffer {
const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, frameColorRGB, drawFrame } = this.config;
const frame = drawFrame
? `<rect x="0" y="0" width="100%" height="100%" fill="none" stroke="rgb(${frameColorRGB.r},${frameColorRGB.g},${frameColorRGB.b})" stroke-width="${this.config.borderWidth}" />`
: '';
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<text y="${textPositionY}" font-family="${fontFace}" font-size="${fontSize}">
<tspan x="${textPositionX}" dy="0" fill="rgb(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})">${customText}</tspan>
<tspan x="${textPositionX}" dy="1.2em" fill="rgb(${this.config.secondaryTextColorRGB.r},${this.config.secondaryTextColorRGB.g},${this.config.secondaryTextColorRGB.b})">${secondaryText}</tspan>
</text>
${frame}
</svg>`;
return Buffer.from(svg);
}
private createSVGNumberOverlay(hits: number, imgWidth: number, imgHeight: number): Buffer {
const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
<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);
}
async render(): Promise<Buffer> {
const hits = await this.counter.getHits();
return await sharp(await this.getBaseImage())
.composite([
{
input: this.createSVGNumberOverlay(hits, this.baseImageMetadata.width!, this.baseImageMetadata.height!),
top: 0,
left: 0
}
])
.toFormat(this.imageFormat)
.toBuffer();
}
}
class HitCounter {
private db: DB;
private renderer: BaseHitCounterRenderer;
private config: HitCounterConfig;
private preparedGet: Statement;
private preparedIncrement: Statement;
private preparedReset: Statement;
private preparedStatements: Record<string, Statement>;
private mimeType: string;
private config: HitCounterConfig;
constructor(renderer: Type<BaseHitCounterRenderer>, config?: HitCounterConfig) {
this.config = { ...defaultHitCounterConfig, ...config };
this.renderer = new renderer(this, this.config);
this.openDB();
this.setupDB();
this.setupStatements();
this.mimeType = mimeTypeMap[fileExt(this.config.imageFile)];
constructor(renderer: Type<BaseHitCounterRenderer>, config: HitCounterConfig = defaultHitCounterConfig) {
config = { ...defaultHitCounterConfig, ...config };
this.config = config;
this.renderer = new renderer(this, config);
this.mimeType = mimeTypeMap[fileExt(config.imageFile)];
this.db = this.initDB(config.counterDB);
this.preparedStatements = this.prepareStatements();
}
private openDB(): void {
const db = new Database(this.config.counterDB, {fileMustExist: false});
private initDB(filePath: string): DB {
const db = new Database(filePath, {fileMustExist: false});
db.pragma('locking_mode = EXCLUSIVE');
db.pragma('journal_mode = WAL');
db.pragma('synchronous = OFF');
this.db = db;
return db;
}
private setupDB(): void {
private prepareStatements(): Record<string, Statement> {
this.db.exec(`
CREATE TABLE IF NOT EXISTS hit_counter (
id INTEGER PRIMARY KEY,
hits INTEGER NOT NULL
hits INTEGER NOT NULL DEFAULT 0
);
`);
this.db.exec(`
INSERT OR IGNORE INTO hit_counter (id, hits)
VALUES (?, ${this.config.siteId})
VALUES (${this.config.siteId}, 0)
`);
}
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 = ?
`);
return {
get: this.db.prepare('SELECT hits FROM hit_counter WHERE id = ?'),
increment: this.db.prepare('UPDATE hit_counter SET hits = hits + 1 WHERE id = ?'),
reset: 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;
const result = this.preparedStatements.get.get(this.config.siteId) as HitResult;
return result.hits;
}
async increment(): Promise<void> {
this.preparedIncrement.run(this.config.siteId);
this.preparedStatements.increment.run(this.config.siteId);
}
async reset(): Promise<void> {
this.preparedReset.run(this.config.siteId);
this.preparedStatements.reset.run(this.config.siteId);
}
async render(): Promise<any> {
@ -379,16 +287,11 @@ class HitCounter {
return this.mimeType;
}
// destructor
destroy(): void {
this.closeDB();
finalize(): void {
this.db.close();
}
}
export {
HitCounter,
HitCounterConfig,
@ -396,5 +299,4 @@ export {
BaseHitCounterRenderer,
ImageHitCounterRenderer
};
export default HitCounter;