diff --git a/node/.gitignore b/node/.gitignore index 995f2b4..47ab89f 100644 --- a/node/.gitignore +++ b/node/.gitignore @@ -2,3 +2,4 @@ node_modules/ .DS_Store hits.db hits.db-* +example_counter_bg.png diff --git a/node/src/hit-counter.ts b/node/src/hit-counter.ts index 72c6054..d116e0c 100644 --- a/node/src/hit-counter.ts +++ b/node/src/hit-counter.ts @@ -1,10 +1,10 @@ -import e, { Request, Response, NextFunction } from 'express'; 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 } from 'sharp'; +import sharp, { FormatEnum, AvailableFormatInfo, Sharp, Metadata } 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 } = { @@ -16,6 +16,16 @@ const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo } 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 { hits: number; } @@ -33,6 +43,50 @@ interface HitCounterRenderer { render(): Promise; } +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; @@ -53,17 +107,31 @@ class BaseHitCounterRenderer implements HitCounterRenderer { 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); - const fileExt = this.config.imageFile.split('.').pop(); - if (!fileExt) { - throw new Error('Invalid file extension'); - } - this.imageType = fileExt; - this.imageFormat = imageFormatMap[fileExt.toLowerCase()]; - if (!this.imageFormat) { - throw new Error('Unsupported file extension'); - } + this.imageType = fileExt(this.config.imageFile); + this.imageFormat = extToFormat(this.imageType); + } + + private async downloadImage(): Promise { + 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 { @@ -78,71 +146,91 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer { } // store the image in the cache - const fileExt = dest.split('.').pop(); - if (!fileExt) { - throw new Error('Invalid file extension'); - } + const ext = fileExt(this.config.backgroundImageUrl); - const format = imageFormatMap[fileExt.toLowerCase()]; - if (!format) { - throw new Error('Unsupported file extension'); + 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'); + } } - - const image = sharp(this.config.backgroundImageUrl).toFormat(format); - await image.toFile(dest); - return image; } - - - async render(): Promise { - const hits = await this.counter.getHits(); + async prepareBaseImage(): Promise { + if (typeof this.baseImage !== 'undefined') { + return; + } const bgImage = await this.cachedBackgroundImage(); const bgInfo = await bgImage.metadata(); - - const text = this.config.customText; - const secondaryText = this.config.secondaryText; - const textColor = this.config.textColorRGB; - 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 imgWidth = bgInfo.width; + const imgHeight = bgInfo.height; + if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined') { + throw new Error('Invalid image dimensions'); + } const svgFrame = ` - + `; const svgOverlay = ` - ${text} - ${secondaryText} - ${hits} + ${this.config.customText} + ${this.config.secondaryText} `; - - const overlay = sharp(Buffer.from(svgOverlay)); - if (drawFrame) { + + if (this.config.drawFrame) { const frame = sharp(Buffer.from(svgFrame)); const composite = bgImage.composite([ { input: await overlay.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([ { 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 { + 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 = ` + + ${hits} + + `; + 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 numberPositionX: number; // X 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 secondaryTextColorRGB: RGBColor; // Secondary text color numberColorRGB: RGBColor; // Number color @@ -184,12 +275,15 @@ const defaultHitCounterConfig: HitCounterConfig = { secondaryText: 'Super cyber, super cool!', textPositionX: 5, textPositionY: 5, - numberPositionX: 5, + numberPositionX: 160, numberPositionY: 15, + fontSize: 12, + fontFace: 'Arial', + borderWidth: 3, textColorRGB: { r: 253, g: 252, b: 1 }, secondaryTextColorRGB: { r: 0, g: 255, b: 0 }, numberColorRGB: { r: 255, g: 255, b: 255 }, - frameColorRGB: { r: 255, g: 238, b: 0 }, + frameColorRGB: { r: 255, g: 0, b: 0 }, drawFrame: true, renderer: ImageHitCounterRenderer }; @@ -201,6 +295,7 @@ class HitCounter { private preparedGet: Statement; private preparedIncrement: Statement; private preparedReset: Statement; + private mimeType: string; constructor(renderer: Type, config?: HitCounterConfig) { this.config = { ...defaultHitCounterConfig, ...config }; @@ -208,6 +303,7 @@ class HitCounter { this.openDB(); this.setupDB(); this.setupStatements(); + this.mimeType = mimeTypeMap[fileExt(this.config.imageFile)]; } private openDB(): void { @@ -266,6 +362,14 @@ class HitCounter { this.preparedReset.run(this.config.siteId); } + async render(): Promise { + return this.renderer.render(); + } + + getMimeType(): string { + return this.mimeType; + } + // destructor destroy(): void { this.closeDB(); diff --git a/node/src/index.ts b/node/src/index.ts index 7003aa8..c3647a6 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -4,18 +4,29 @@ import { HitCounter, ImageHitCounterRenderer } from "./hit-counter"; const app: Express = express(); const PORT: number = 8000; const hitCounter = new HitCounter(ImageHitCounterRenderer); +const imageMimeType = hitCounter.getMimeType(); 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) => { - const hits = await hitCounter.getHits(); - res.send(`This site has ${hits} hits!`); + const hits = await hitCounter.getHits(); + 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, () => { console.log(`[server]: Express server started on port ${PORT}`);