forked from revengeday/hit-counter
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
|
.DS_Store
|
||||||
hits.db
|
hits.db
|
||||||
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 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;
|
|
||||||
this.imageFormat = imageFormatMap[fileExt.toLowerCase()];
|
|
||||||
if (!this.imageFormat) {
|
|
||||||
throw new Error('Unsupported file extension');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
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()];
|
|
||||||
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);
|
const image = sharp(this.config.backgroundImageUrl).toFormat(format);
|
||||||
await image.toFile(dest);
|
await image.toFile(dest);
|
||||||
return image;
|
return image;
|
||||||
|
} else {
|
||||||
|
throw new Error('Background image does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
|
@ -4,10 +4,12 @@ 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) => {
|
||||||
|
@ -15,7 +17,16 @@ app.get("/hits", async (req: Request, res: Response) => {
|
||||||
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}`);
|
||||||
|
|
Loading…
Reference in a new issue