feature/nodejs_implementation #1

Open
zeyus wants to merge 8 commits from zeyus/hit-counter:feature/nodejs_implementation into main
3 changed files with 169 additions and 53 deletions
Showing only changes of commit 477301157e - Show all commits

1
node/.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules/
.DS_Store .DS_Store
hits.db hits.db
hits.db-* hits.db-*
example_counter_bg.png

View file

@ -1,10 +1,10 @@
import e, { Request, Response, NextFunction } from 'express';
import { type Database as DB } from 'better-sqlite3'; import { type Database as DB } from 'better-sqlite3';
import { type Statement } from 'better-sqlite3'; import { type Statement } from 'better-sqlite3';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
// import sharp for images // import sharp for images
import sharp, { FormatEnum, AvailableFormatInfo, Sharp } from 'sharp'; import sharp, { FormatEnum, AvailableFormatInfo, Sharp, Metadata } from 'sharp';
import * as fs from 'fs'; import * as fs from 'fs';
import * as https from 'https';
// Map file extensions to sharp formats // Map file extensions to sharp formats
const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo } = { const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo } = {
@ -16,6 +16,16 @@ const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo }
gif: 'gif' gif: 'gif'
}; };
// no need for third party library
const mimeTypeMap: { [key: string]: string } = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
tiff: 'image/tiff',
gif: 'image/gif'
};
interface HitResult { interface HitResult {
hits: number; hits: number;
} }
@ -33,6 +43,50 @@ interface HitCounterRenderer {
render(): Promise<any>; 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 { class BaseHitCounterRenderer implements HitCounterRenderer {
protected counter: HitCounter; protected counter: HitCounter;
protected config: HitCounterConfig; protected config: HitCounterConfig;
@ -53,17 +107,31 @@ class BaseHitCounterRenderer implements HitCounterRenderer {
class ImageHitCounterRenderer extends BaseHitCounterRenderer { class ImageHitCounterRenderer extends BaseHitCounterRenderer {
private imageType: string; private imageType: string;
private imageFormat: keyof FormatEnum | AvailableFormatInfo; private imageFormat: keyof FormatEnum | AvailableFormatInfo;
private baseImage: Buffer;
private baseImageMetadata: Metadata;
constructor(counter: HitCounter, config: HitCounterConfig) { constructor(counter: HitCounter, config: HitCounterConfig) {
super(counter, config); super(counter, config);
const fileExt = this.config.imageFile.split('.').pop(); this.imageType = fileExt(this.config.imageFile);
if (!fileExt) { this.imageFormat = extToFormat(this.imageType);
throw new Error('Invalid file extension'); }
}
this.imageType = fileExt; private async downloadImage(): Promise<Buffer> {
this.imageFormat = imageFormatMap[fileExt.toLowerCase()]; return new Promise((resolve, reject) => {
if (!this.imageFormat) { let buf = Buffer.alloc(0);
throw new Error('Unsupported file extension'); 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> { private async cachedBackgroundImage(overWrite: boolean = false): Promise<Sharp> {
@ -78,71 +146,91 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer {
} }
// store the image in the cache // store the image in the cache
const fileExt = dest.split('.').pop(); const ext = fileExt(this.config.backgroundImageUrl);
if (!fileExt) {
throw new Error('Invalid file extension');
}
const format = imageFormatMap[fileExt.toLowerCase()]; const format = extToFormat(ext);
if (!format) { if (isWebURI(this.config.backgroundImageUrl)) {
throw new Error('Unsupported file extension'); 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');
}
} }
const image = sharp(this.config.backgroundImageUrl).toFormat(format);
await image.toFile(dest);
return image;
} }
async prepareBaseImage(): Promise<void> {
if (typeof this.baseImage !== 'undefined') {
async render(): Promise<Sharp> { return;
const hits = await this.counter.getHits(); }
const bgImage = await this.cachedBackgroundImage(); const bgImage = await this.cachedBackgroundImage();
const bgInfo = await bgImage.metadata(); const bgInfo = await bgImage.metadata();
const imgWidth = bgInfo.width;
const text = this.config.customText; const imgHeight = bgInfo.height;
const secondaryText = this.config.secondaryText; if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined') {
const textColor = this.config.textColorRGB; throw new Error('Invalid image dimensions');
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 = ` const svgFrame = `
<svg width="${bgInfo.width}" height="${bgInfo.height}" xmlns="http://www.w3.org/2000/svg"> <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"/> <rect x="${this.config.borderWidth}" y="${this.config.borderWidth}" width="${imgWidth - 2 * this.config.borderWidth}" height="${imgHeight - 2 * this.config.borderWidth}" fill="none" stroke-alignment="inner" stroke="rgb(${this.config.frameColorRGB.r},${this.config.frameColorRGB.g},${this.config.frameColorRGB.b})" stroke-width="${this.config.borderWidth}"/>
</svg> </svg>
`; `;
const svgOverlay = ` const svgOverlay = `
<svg width="${bgInfo.width}" height="${bgInfo.height}" xmlns="http://www.w3.org/2000/svg"> <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="${this.config.textPositionX}" y="${this.config.textPositionY}" fill="rgb(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})" font-size="${this.config.fontSize}" font-family="${this.config.fontFace}">${this.config.customText}</text>
<text x="${textX}" y="${textY + 15}" fill="rgb(${secondaryTextColor.r},${secondaryTextColor.g},${secondaryTextColor.b})" font-size="12">${secondaryText}</text> <text x="${this.config.textPositionX}" y="${this.config.textPositionY + 15}" fill="rgb(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})" font-size="${this.config.fontSize}" font-family="${this.config.fontFace}">${this.config.secondaryText}</text>
<text x="${numberX}" y="${numberY}" fill="rgb(${numberColor.r},${numberColor.g},${numberColor.b})" font-size="12">${hits}</text>
</svg> </svg>
`; `;
const overlay = sharp(Buffer.from(svgOverlay)); const overlay = sharp(Buffer.from(svgOverlay));
if (drawFrame) {
if (this.config.drawFrame) {
const frame = sharp(Buffer.from(svgFrame)); const frame = sharp(Buffer.from(svgFrame));
const composite = bgImage.composite([ const composite = bgImage.composite([
{ input: await overlay.toBuffer(), gravity: 'northwest' }, { input: await overlay.toBuffer(), gravity: 'northwest' },
{ input: await frame.toBuffer(), gravity: 'northwest' } { input: await frame.toBuffer(), gravity: 'northwest' }
]); ]);
return composite; const preparedImg = composite.toFormat(this.imageFormat);
this.baseImageMetadata = await preparedImg.metadata();
this.baseImage = await preparedImg.toBuffer();;
} }
const composite = bgImage.composite([ const composite = bgImage.composite([
{ input: await overlay.toBuffer(), gravity: 'northwest' } { input: await overlay.toBuffer(), gravity: 'northwest' }
]); ]);
return composite; const preparedImg = composite.toFormat(this.imageFormat);
this.baseImageMetadata = await preparedImg.metadata();
this.baseImage = await preparedImg.toBuffer();
}
async render(): Promise<Buffer> {
const hits = await this.counter.getHits();
const numberColor = this.config.numberColorRGB;
const numberX = this.config.numberPositionX;
const numberY = this.config.numberPositionY;
await this.prepareBaseImage();
const overlay = `
<svg width="${this.baseImageMetadata.width}" height="${this.baseImageMetadata.height}" xmlns="http://www.w3.org/2000/svg">
<text x="${numberX}" y="${numberY}" fill="rgb(${numberColor.r},${numberColor.g},${numberColor.b})" font-size="12">${hits}</text>
</svg>
`;
const composite = sharp(this.baseImage).composite([
{ input: Buffer.from(overlay), gravity: 'northwest' }
]);
return composite.toBuffer();
} }
} }
@ -163,6 +251,9 @@ interface HitCounterConfig {
textPositionY: number; // Y position of the text textPositionY: number; // Y position of the text
numberPositionX: number; // X position of the number numberPositionX: number; // X position of the number
numberPositionY: number; // Y position of the number numberPositionY: number; // Y position of the number
fontSize: number; // Font size
fontFace: string; // Font face
borderWidth: number; // Border width
textColorRGB: RGBColor; // Text color textColorRGB: RGBColor; // Text color
secondaryTextColorRGB: RGBColor; // Secondary text color secondaryTextColorRGB: RGBColor; // Secondary text color
numberColorRGB: RGBColor; // Number color numberColorRGB: RGBColor; // Number color
@ -184,12 +275,15 @@ const defaultHitCounterConfig: HitCounterConfig = {
secondaryText: 'Super cyber, super cool!', secondaryText: 'Super cyber, super cool!',
textPositionX: 5, textPositionX: 5,
textPositionY: 5, textPositionY: 5,
numberPositionX: 5, numberPositionX: 160,
numberPositionY: 15, numberPositionY: 15,
fontSize: 12,
fontFace: 'Arial',
borderWidth: 3,
textColorRGB: { r: 253, g: 252, b: 1 }, textColorRGB: { r: 253, g: 252, b: 1 },
secondaryTextColorRGB: { r: 0, g: 255, b: 0 }, secondaryTextColorRGB: { r: 0, g: 255, b: 0 },
numberColorRGB: { r: 255, g: 255, b: 255 }, numberColorRGB: { r: 255, g: 255, b: 255 },
frameColorRGB: { r: 255, g: 238, b: 0 }, frameColorRGB: { r: 255, g: 0, b: 0 },
drawFrame: true, drawFrame: true,
renderer: ImageHitCounterRenderer renderer: ImageHitCounterRenderer
}; };
@ -201,6 +295,7 @@ class HitCounter {
private preparedGet: Statement; private preparedGet: Statement;
private preparedIncrement: Statement; private preparedIncrement: Statement;
private preparedReset: Statement; private preparedReset: Statement;
private mimeType: string;
constructor(renderer: Type<BaseHitCounterRenderer>, config?: HitCounterConfig) { constructor(renderer: Type<BaseHitCounterRenderer>, config?: HitCounterConfig) {
this.config = { ...defaultHitCounterConfig, ...config }; this.config = { ...defaultHitCounterConfig, ...config };
@ -208,6 +303,7 @@ class HitCounter {
this.openDB(); this.openDB();
this.setupDB(); this.setupDB();
this.setupStatements(); this.setupStatements();
this.mimeType = mimeTypeMap[fileExt(this.config.imageFile)];
} }
private openDB(): void { private openDB(): void {
@ -266,6 +362,14 @@ class HitCounter {
this.preparedReset.run(this.config.siteId); this.preparedReset.run(this.config.siteId);
} }
async render(): Promise<any> {
return this.renderer.render();
}
getMimeType(): string {
return this.mimeType;
}
// destructor // destructor
destroy(): void { destroy(): void {
this.closeDB(); this.closeDB();

View file

@ -4,18 +4,29 @@ import { HitCounter, ImageHitCounterRenderer } from "./hit-counter";
const app: Express = express(); const app: Express = express();
const PORT: number = 8000; const PORT: number = 8000;
const hitCounter = new HitCounter(ImageHitCounterRenderer); const hitCounter = new HitCounter(ImageHitCounterRenderer);
const imageMimeType = hitCounter.getMimeType();
app.get("/", (req: Request, res: Response) => { app.get("/", (req: Request, res: Response) => {
res.send("Hello World!"); hitCounter.increment();
hitCounter.increment(); res.send("Hello dear interlocutor! Your visit has been noted.");
}); });
app.get("/hits", async (req: Request, res: Response) => { app.get("/hits", async (req: Request, res: Response) => {
const hits = await hitCounter.getHits(); const hits = await hitCounter.getHits();
res.send(`This site has ${hits} hits!`); res.send(`This site has ${hits} hits!`);
}); });
app.get("/reset", async (req: Request, res: Response) => {
await hitCounter.reset();
res.send("Hit counter has been reset!");
});
app.get("/image", async (req: Request, res: Response) => {
const image = await hitCounter.render();
res.setHeader("Content-Type", imageMimeType);
res.send(image);
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`[server]: Express server started on port ${PORT}`); console.log(`[server]: Express server started on port ${PORT}`);