diff --git a/node/README.md b/node/README.md index 8160a2a..d81af7a 100644 --- a/node/README.md +++ b/node/README.md @@ -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 diff --git a/node/src/hit-counter.ts b/node/src/hit-counter.ts index dc419b4..a148ccc 100644 --- a/node/src/hit-counter.ts +++ b/node/src/hit-counter.ts @@ -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 = { 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 = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', @@ -38,214 +33,10 @@ interface RGBColor { export interface Type extends Function { new (...args: any[]): T; } - 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; - - constructor(counter: HitCounter, config: HitCounterConfig) { - this.counter = counter; - this.config = config; - } - - async render(): Promise { - // 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 { - 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 { - // 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 { - 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 = ``; - - } - - const svgOverlay = ` - - - ${this.config.customText} - ${this.config.secondaryText} - - ${svgFrame} - - `; - - 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 { - 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 = ` - - ${hits} - - `; - - 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 { + 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 { + 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 { + 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 { + 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 + ? `` + : ''; + + const svg = ` + + + ${customText} + ${secondaryText} + + ${frame} + `; + return Buffer.from(svg); + } + + private createSVGNumberOverlay(hits: number, imgWidth: number, imgHeight: number): Buffer { + const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config; + const svg = ` + + ${hits} + `; + return Buffer.from(svg); + } + + async render(): Promise { + 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; private mimeType: string; + private config: HitCounterConfig; - constructor(renderer: Type, 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, 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 { 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 { - 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 { - this.preparedIncrement.run(this.config.siteId); + this.preparedStatements.increment.run(this.config.siteId); } async reset(): Promise { - this.preparedReset.run(this.config.siteId); + this.preparedStatements.reset.run(this.config.siteId); } async render(): Promise { @@ -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;