WIP, pretty much there with serving image, just some layout stuff.
This commit is contained in:
		
							parent
							
								
									e3e483e40b
								
							
						
					
					
						commit
						477301157e
					
				
					 3 changed files with 169 additions and 53 deletions
				
			
		
							
								
								
									
										1
									
								
								node/.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								node/.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -2,3 +2,4 @@ node_modules/ | |||
| .DS_Store | ||||
| hits.db | ||||
| hits.db-* | ||||
| example_counter_bg.png | ||||
|  |  | |||
|  | @ -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<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; | ||||
|  | @ -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<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> { | ||||
|  | @ -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<Sharp> { | ||||
|         const hits = await this.counter.getHits(); | ||||
|     async prepareBaseImage(): Promise<void> { | ||||
|         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 = ` | ||||
|             <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> | ||||
|         `;
 | ||||
|          | ||||
|         const svgOverlay = ` | ||||
|             <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="${textX}" y="${textY + 15}" fill="rgb(${secondaryTextColor.r},${secondaryTextColor.g},${secondaryTextColor.b})" font-size="12">${secondaryText}</text> | ||||
|                 <text x="${numberX}" y="${numberY}" fill="rgb(${numberColor.r},${numberColor.g},${numberColor.b})" font-size="12">${hits}</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="${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> | ||||
|             </svg> | ||||
|         `;
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|         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<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
 | ||||
|     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<BaseHitCounterRenderer>, 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<any> { | ||||
|         return this.renderer.render(); | ||||
|     } | ||||
| 
 | ||||
|     getMimeType(): string { | ||||
|         return this.mimeType; | ||||
|     } | ||||
| 
 | ||||
|     // destructor
 | ||||
|     destroy(): void { | ||||
|         this.closeDB(); | ||||
|  |  | |||
|  | @ -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}`); | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue