forked from revengeday/hit-counter
Compare commits
12 commits
Author | SHA1 | Date | |
---|---|---|---|
2de8e98417 | |||
a08c8616d1 | |||
5d1d67836c | |||
a2e698d2f6 | |||
51cd15aa4f | |||
0c744f0883 | |||
b6f83af72e | |||
654eaa5c59 | |||
b08f259447 | |||
477301157e | |||
e3e483e40b | |||
dc932eded7 |
7 changed files with 3352 additions and 0 deletions
5
node/.gitignore
vendored
Normal file
5
node/.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
hits.db
|
||||||
|
hits.db-*
|
||||||
|
example_counter_bg.png
|
152
node/README.md
Normal file
152
node/README.md
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
# Hit Counter - Because Nothing Says Retro Like a Hit Counter
|
||||||
|
|
||||||
|
[Remember Hit Counters? Well, They’re Back. And They’re Gloriously useless](https://datakra.sh/logs/remember-hit-counters-well-theyre-back-and-theyre-gloriously-useless), personal websites were as unique as fingerprints, and every site proudly flaunted a hit counter? It was the ultimate digital badge of honor—a glaring declaration of how many lost souls had wandered onto your site. Fast forward to now, and it turns out you can relive those glitzy days. Yup, hit counters are back, and they are as gloriously pointless as ever!
|
||||||
|
|
||||||
|
## Why, Though?
|
||||||
|
|
||||||
|
Why on Satans earth would anyone need a hit counter in 2024? Honestly, I haven't got the slightest idea. Most modern web metrics tools can provide far more detailed insights. But hey, who says everything needs to make sense? Sometimes, a little nostalgia is all you need to spice things up.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Custom Main Text and Secondary Text**: Because why settle for one when you can have two?
|
||||||
|
- **Hit Counter**: See how many times the page is hit. It’s like a pissing contest, but for web traffic.
|
||||||
|
- **Frame Option**: For when you want to make things a tad more "extra."
|
||||||
|
- **Custom Colors**: Match your counter to your site's funky style.
|
||||||
|
- **Custom Output**: jpg, png, webp, tiff, gif. High-tech stuff.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 22.x or higher
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Clone this fantastic repository**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.cyberwa.re/revengeday/hit-counter.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Navigate** to the project directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd hit-counter/node
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run devserver** to test it out locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Enjoy the Retro Vibes**: Access the script from your web browser or via `curl`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
http://localhost:8000/
|
||||||
|
http://localhost:8000/hits # to see the count
|
||||||
|
http://localhost:8000/image # to see the image
|
||||||
|
http://localhost:8000/reset # to reset the count
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// the config is defined in the HitCounterConfig interface
|
||||||
|
interface HitCounterConfig {
|
||||||
|
siteId: number;
|
||||||
|
// path to the database file
|
||||||
|
counterDB: string;
|
||||||
|
// Background image URL for the counter display
|
||||||
|
backgroundImageUrl: string;
|
||||||
|
// local image path
|
||||||
|
imageFile: string;
|
||||||
|
backgroundCacheFile: string;
|
||||||
|
// Text settings to be displayed on the image
|
||||||
|
customText: string; // Main headline text
|
||||||
|
secondaryText: string; // Secondary descriptive text
|
||||||
|
textPositionX: number; // X position of the text
|
||||||
|
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
|
||||||
|
frameColorRGB: RGBColor; // Frame color
|
||||||
|
drawFrame: boolean; // Whether to draw a frame around the counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// currently only the ImageHitCounterRenderer is implemented, but this could be extended
|
||||||
|
// to a canvas or html or whatever renderer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { HitCounter } from "./hit-counter";
|
||||||
|
|
||||||
|
|
||||||
|
// default config
|
||||||
|
defaultHitCounterConfig = {
|
||||||
|
siteId: 1,
|
||||||
|
counterDB: './hits.db',
|
||||||
|
backgroundImageUrl: 'https://datakra.sh/assets/example_counter.jpg',
|
||||||
|
imageFile: './example_counter.png',
|
||||||
|
backgroundCacheFile: './example_counter_bg.png',
|
||||||
|
customText: 'Hit Counter!',
|
||||||
|
secondaryText: 'Super cyber, super cool!',
|
||||||
|
textPositionX: 5,
|
||||||
|
textPositionY: 11,
|
||||||
|
numberPositionX: 170,
|
||||||
|
numberPositionY: 18,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFace: 'fixed',
|
||||||
|
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: 0, b: 0 },
|
||||||
|
drawFrame: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// the default config will be used, but you can pass a custom config object as
|
||||||
|
// the first argument to the HitCounter constructor
|
||||||
|
// e.g. const hitCounter = new HitCounter(customConfig);
|
||||||
|
// the second argument is reserved for the renderer, but currently only the ImageHitCounterRenderer is implemented
|
||||||
|
const hitCounter = new HitCounter();
|
||||||
|
|
||||||
|
// increment the hit counter
|
||||||
|
hitCounter.increment(); // async function
|
||||||
|
|
||||||
|
// get the current hit count
|
||||||
|
const count = await hitCounter.getCount();
|
||||||
|
|
||||||
|
// get mime type
|
||||||
|
const imageMimeType = hitCounter.getMimeType();
|
||||||
|
|
||||||
|
// render the image
|
||||||
|
const image = await hitCounter.render();
|
||||||
|
|
||||||
|
// send to client however you like
|
||||||
|
|
||||||
|
// reset the hit counter
|
||||||
|
await hitCounter.reset();
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Want to make this even more useless? Fork it, hack it, and send me a pull request.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Got questions or just feeling nostalgic? Hit me up on the fediverse: [@revengeday@corteximplant.com](https://corteximplant.com/@revengeday).
|
6
node/nodemon.json
Normal file
6
node/nodemon.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"watch": ["src"],
|
||||||
|
"ext": "ts,json",
|
||||||
|
"ignore": ["src/**/*.spec.ts"],
|
||||||
|
"exec": "npx tsx ./src/index.ts"
|
||||||
|
}
|
2668
node/package-lock.json
generated
Normal file
2668
node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
node/package.json
Normal file
27
node/package.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "hit_counter",
|
||||||
|
"version": "0.0.2",
|
||||||
|
"type": "module",
|
||||||
|
"description": "A simple hit counter",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.5.0",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "npx tsc",
|
||||||
|
"start": "npm run build && node dist/index.js",
|
||||||
|
"dev": "npx nodemon"
|
||||||
|
},
|
||||||
|
"author": "zeyus",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.8.7",
|
||||||
|
"nodemon": "^3.1.7",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
461
node/src/hit-counter.ts
Normal file
461
node/src/hit-counter.ts
Normal file
|
@ -0,0 +1,461 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Define the image format map
|
||||||
|
const imageFormatMap: Record<string, keyof FormatEnum> = {
|
||||||
|
jpg: 'jpeg',
|
||||||
|
jpeg: 'jpeg',
|
||||||
|
png: 'png',
|
||||||
|
webp: 'webp',
|
||||||
|
tiff: 'tiff',
|
||||||
|
gif: 'gif'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the MIME type map
|
||||||
|
const mimeTypeMap: Record<string, string> = {
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
webp: 'image/webp',
|
||||||
|
tiff: 'image/tiff',
|
||||||
|
gif: 'image/gif'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Database result for hits
|
||||||
|
interface HitResult {
|
||||||
|
hits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB color type
|
||||||
|
interface RGBColor {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows use of base class and subclass as a valid type
|
||||||
|
export interface Type<T> extends Function { new (...args: any[]): T; }
|
||||||
|
|
||||||
|
// Hit counter renderer interface
|
||||||
|
interface HitCounterRenderer {
|
||||||
|
render(): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit counter configuration
|
||||||
|
interface HitCounterConfig {
|
||||||
|
siteId: number;
|
||||||
|
// path to the database file
|
||||||
|
counterDB: string;
|
||||||
|
// Background image URL for the counter display
|
||||||
|
backgroundImageUrl: string;
|
||||||
|
// local image path
|
||||||
|
imageFile: string;
|
||||||
|
backgroundCacheFile: string;
|
||||||
|
// Text settings to be displayed on the image
|
||||||
|
customText: string; // Main headline text
|
||||||
|
secondaryText: string; // Secondary descriptive text
|
||||||
|
textPositionX: number; // X position of the text
|
||||||
|
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
|
||||||
|
frameColorRGB: RGBColor; // Frame color
|
||||||
|
drawFrame: boolean; // Whether to draw a frame around the counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default configuration for the hit counter
|
||||||
|
const defaultHitCounterConfig: HitCounterConfig = {
|
||||||
|
siteId: 1,
|
||||||
|
counterDB: './hits.db',
|
||||||
|
backgroundImageUrl: 'https://datakra.sh/assets/example_counter.jpg',
|
||||||
|
imageFile: './example_counter.png',
|
||||||
|
backgroundCacheFile: './example_counter_bg.png',
|
||||||
|
customText: 'Hit Counter!',
|
||||||
|
secondaryText: 'Super cyber, super cool!',
|
||||||
|
textPositionX: 5,
|
||||||
|
textPositionY: 11,
|
||||||
|
numberPositionX: 170,
|
||||||
|
numberPositionY: 18,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFace: 'fixed',
|
||||||
|
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: 0, b: 0 },
|
||||||
|
drawFrame: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the file extension from a file path.
|
||||||
|
*
|
||||||
|
* @param {String} file
|
||||||
|
*
|
||||||
|
* @returns {String}
|
||||||
|
* @throws {Error} if the file extension is invalid
|
||||||
|
*/
|
||||||
|
function fileExt(file: string): string {
|
||||||
|
const ext = file.split('.').pop();
|
||||||
|
if (!ext) throw new Error('Invalid file extension');
|
||||||
|
return ext.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file extension to a Sharp image format.
|
||||||
|
*
|
||||||
|
* @param {String} ext
|
||||||
|
*
|
||||||
|
* @returns {String}
|
||||||
|
* @throws {Error} if the file extension is not supported
|
||||||
|
*/
|
||||||
|
function extToFormat(ext: string): keyof FormatEnum {
|
||||||
|
const format = imageFormatMap[ext];
|
||||||
|
if (!format) throw new Error('Unsupported file extension');
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path is a web URI.
|
||||||
|
*
|
||||||
|
* @param {String|URL} path
|
||||||
|
* The path to check.
|
||||||
|
*
|
||||||
|
* @returns {Boolean}
|
||||||
|
* Whether the path is a web URI.
|
||||||
|
*/
|
||||||
|
function isWebURI(path: string | URL): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(path.toString());
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for hit counter renderers.
|
||||||
|
*
|
||||||
|
* @class BaseHitCounterRenderer
|
||||||
|
* @implements {HitCounterRenderer}
|
||||||
|
*/
|
||||||
|
class BaseHitCounterRenderer implements HitCounterRenderer {
|
||||||
|
protected counter: HitCounter;
|
||||||
|
protected config: HitCounterConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance of BaseHitCounterRenderer.
|
||||||
|
*
|
||||||
|
* @param {HitCounter} counter
|
||||||
|
* The hit counter instance.
|
||||||
|
* @param {HitCounterConfig} config
|
||||||
|
* The hit counter configuration.
|
||||||
|
*/
|
||||||
|
constructor(counter: HitCounter, config: HitCounterConfig) {
|
||||||
|
this.counter = counter;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the hit counter.
|
||||||
|
*/
|
||||||
|
async render(): Promise<any> {
|
||||||
|
throw new Error('Render not implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image hit counter renderer.
|
||||||
|
*
|
||||||
|
* @class ImageHitCounterRenderer
|
||||||
|
* @extends {BaseHitCounterRenderer}
|
||||||
|
*/
|
||||||
|
class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
||||||
|
private imageFormat: keyof FormatEnum;
|
||||||
|
private baseImage: Buffer;
|
||||||
|
private baseImageMetadata: Metadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance of ImageHitCounterRenderer.
|
||||||
|
*
|
||||||
|
* @param {HitCounter} counter
|
||||||
|
* The hit counter instance.
|
||||||
|
* @param {HitCounterConfig} config
|
||||||
|
* The hit counter configuration.
|
||||||
|
*/
|
||||||
|
constructor(counter: HitCounter, config: HitCounterConfig) {
|
||||||
|
super(counter, config);
|
||||||
|
this.imageFormat = extToFormat(fileExt(this.config.imageFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the background image.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
* The downloaded image.
|
||||||
|
*/
|
||||||
|
private async downloadImage(): Promise<Buffer> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load or cache the background image.
|
||||||
|
*
|
||||||
|
* @param {Boolean} [overwrite=false]
|
||||||
|
* Whether to overwrite the cached background image.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Sharp>}
|
||||||
|
* The loaded or cached background image.
|
||||||
|
*/
|
||||||
|
private async loadOrCacheBG(overwrite = false): Promise<Sharp> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base image.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
* The base image.
|
||||||
|
*/
|
||||||
|
private async getBaseImage(): Promise<Buffer> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an SVG overlay.
|
||||||
|
*
|
||||||
|
* @param {Number} width
|
||||||
|
* The width of the overlay.
|
||||||
|
* @param {Number} height
|
||||||
|
* The height of the overlay.
|
||||||
|
*
|
||||||
|
* @returns {Buffer}
|
||||||
|
* The SVG overlay.
|
||||||
|
*/
|
||||||
|
private createSvgOverlay(width: number, height: number): Buffer {
|
||||||
|
const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, textColorRGB, secondaryTextColorRGB, frameColorRGB, drawFrame, borderWidth } = this.config;
|
||||||
|
const frame = drawFrame
|
||||||
|
? `<rect x="0" y="0" width="100%" height="100%" fill="none" stroke="rgb(${frameColorRGB.r},${frameColorRGB.g},${frameColorRGB.b})" stroke-width="${borderWidth}" />`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
||||||
|
<text y="${textPositionY}" font-family="${fontFace}" font-size="${fontSize}">
|
||||||
|
<tspan x="${textPositionX}" dy="0" fill="rgb(${textColorRGB.r},${textColorRGB.g},${textColorRGB.b})">${customText}</tspan>
|
||||||
|
<tspan x="${textPositionX}" dy="1.2em" fill="rgb(${secondaryTextColorRGB.r},${secondaryTextColorRGB.g},${secondaryTextColorRGB.b})">${secondaryText}</tspan>
|
||||||
|
</text>
|
||||||
|
${frame}
|
||||||
|
</svg>`;
|
||||||
|
return Buffer.from(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an SVG number overlay.
|
||||||
|
*
|
||||||
|
* @param {Number} hits
|
||||||
|
* The number of hits.
|
||||||
|
* @param {Number} width
|
||||||
|
* The width of the image.
|
||||||
|
* @param {Number} height
|
||||||
|
* The height of the image.
|
||||||
|
*
|
||||||
|
* @returns {Buffer}
|
||||||
|
* The SVG number overlay.
|
||||||
|
*/
|
||||||
|
private createSVGNumberOverlay(hits: number, width: number, height: number): Buffer {
|
||||||
|
const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config;
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
||||||
|
<text x="${numberPositionX}" y="${numberPositionY}" text-anchor="end" font-family="${fontFace}" font-size="${fontSize}" fill="rgb(${numberColorRGB.r},${numberColorRGB.g},${numberColorRGB.b})">${hits}</text>
|
||||||
|
</svg>`;
|
||||||
|
return Buffer.from(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the hit counter.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
* The rendered hit counter.
|
||||||
|
*/
|
||||||
|
async render(): Promise<Buffer> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit counter class.
|
||||||
|
*
|
||||||
|
* @class HitCounter
|
||||||
|
*/
|
||||||
|
class HitCounter {
|
||||||
|
private db: DB;
|
||||||
|
private renderer: BaseHitCounterRenderer;
|
||||||
|
private preparedStatements: Record<string, Statement>;
|
||||||
|
private mimeType: string;
|
||||||
|
private config: HitCounterConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance of HitCounter.
|
||||||
|
*
|
||||||
|
* @param {HitCounterConfig} [config=defaultHitCounterConfig]
|
||||||
|
* The hit counter configuration. Defaults to defaultHitCounterConfig.
|
||||||
|
* @param {Type<BaseHitCounterRenderer>} renderer
|
||||||
|
* The hit counter renderer. Defaults to ImageHitCounterRenderer.
|
||||||
|
*/
|
||||||
|
constructor(config: HitCounterConfig = defaultHitCounterConfig, renderer: Type<BaseHitCounterRenderer> = ImageHitCounterRenderer) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the database connection.
|
||||||
|
*
|
||||||
|
* @param {String} filePath
|
||||||
|
* The path to the database file.
|
||||||
|
*
|
||||||
|
* @returns {DB}
|
||||||
|
* The database instance.
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the SQL statements.
|
||||||
|
*
|
||||||
|
* @returns {Record<string, Statement>}
|
||||||
|
* The prepared statements.
|
||||||
|
*/
|
||||||
|
private prepareStatements(): Record<string, Statement> {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hit_counter (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
hits INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
INSERT OR IGNORE INTO hit_counter (id, hits)
|
||||||
|
VALUES (${this.config.siteId}, 0)
|
||||||
|
`);
|
||||||
|
|
||||||
|
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 = ?'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of hits.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Number>}
|
||||||
|
* The number of hits
|
||||||
|
*/
|
||||||
|
async getHits(): Promise<number> {
|
||||||
|
const result = this.preparedStatements.get.get(this.config.siteId) as HitResult;
|
||||||
|
return result.hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the hit counter.
|
||||||
|
*/
|
||||||
|
async increment(): Promise<void> {
|
||||||
|
this.preparedStatements.increment.run(this.config.siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the hit counter
|
||||||
|
*/
|
||||||
|
async reset(): Promise<void> {
|
||||||
|
this.preparedStatements.reset.run(this.config.siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the hit counter.
|
||||||
|
*
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
* The rendered hit counter.
|
||||||
|
*/
|
||||||
|
async render(): Promise<any> {
|
||||||
|
return this.renderer.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the MIME type of the hit counter image.
|
||||||
|
*
|
||||||
|
* @returns {String}
|
||||||
|
* The MIME type.
|
||||||
|
*/
|
||||||
|
getMimeType(): string {
|
||||||
|
return this.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destructor.
|
||||||
|
*/
|
||||||
|
finalize(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
HitCounter,
|
||||||
|
HitCounterConfig,
|
||||||
|
HitCounterRenderer,
|
||||||
|
BaseHitCounterRenderer,
|
||||||
|
ImageHitCounterRenderer
|
||||||
|
};
|
||||||
|
export default HitCounter;
|
33
node/src/index.ts
Normal file
33
node/src/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import express, { Express, Request, Response } from "express";
|
||||||
|
import { HitCounter } from "./hit-counter";
|
||||||
|
|
||||||
|
const app: Express = express();
|
||||||
|
const PORT: number = 8000;
|
||||||
|
const hitCounter = new HitCounter();
|
||||||
|
const imageMimeType = hitCounter.getMimeType();
|
||||||
|
|
||||||
|
app.get("/", (req: Request, res: Response) => {
|
||||||
|
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!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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…
Reference in a new issue