feature/nodejs_implementation #1

Open
zeyus wants to merge 7 commits from zeyus/hit-counter:feature/nodejs_implementation into main
7 changed files with 3192 additions and 0 deletions

5
node/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
.DS_Store
hits.db
hits.db-*
example_counter_bg.png

151
node/README.md Normal file
View file

@ -0,0 +1,151 @@
# Hit Counter - Because Nothing Says Retro Like a Hit Counter
[Remember Hit Counters? Well, Theyre Back. And Theyre 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. Its 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, ImageHitCounterRenderer } 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,
renderer: ImageHitCounterRenderer
};
// 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
// 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
View 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

File diff suppressed because it is too large Load diff

27
node/package.json Normal file
View 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"
}
}

302
node/src/hit-counter.ts Normal file
View file

@ -0,0 +1,302 @@
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';
const imageFormatMap: Record<string, keyof FormatEnum> = {
jpg: 'jpeg',
jpeg: 'jpeg',
png: 'png',
webp: 'webp',
tiff: 'tiff',
gif: 'gif'
};
const mimeTypeMap: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
tiff: 'image/tiff',
gif: 'image/gif'
};
interface HitResult {
hits: number;
}
interface RGBColor {
r: number;
g: number;
b: number;
}
export interface Type<T> extends Function { new (...args: any[]): T; }
interface HitCounterRenderer {
render(): Promise<any>;
}
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
}
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,
};
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<any> {
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<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);
});
}
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;
}
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;
}
private createSvgOverlay(width: number, height: number): Buffer {
const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, frameColorRGB, drawFrame } = 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="${this.config.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(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})">${customText}</tspan>
<tspan x="${textPositionX}" dy="1.2em" fill="rgb(${this.config.secondaryTextColorRGB.r},${this.config.secondaryTextColorRGB.g},${this.config.secondaryTextColorRGB.b})">${secondaryText}</tspan>
</text>
${frame}
</svg>`;
return Buffer.from(svg);
}
private createSVGNumberOverlay(hits: number, imgWidth: number, imgHeight: number): Buffer {
const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
<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);
}
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();
}
}
class HitCounter {
private db: DB;
private renderer: BaseHitCounterRenderer;
private preparedStatements: Record<string, Statement>;
private mimeType: string;
private config: HitCounterConfig;
constructor(renderer: Type<BaseHitCounterRenderer>, 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 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;
}
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 = ?'),
};
}
async getHits(): Promise<number> {
const result = this.preparedStatements.get.get(this.config.siteId) as HitResult;
return result.hits;
}
async increment(): Promise<void> {
this.preparedStatements.increment.run(this.config.siteId);
}
async reset(): Promise<void> {
this.preparedStatements.reset.run(this.config.siteId);
}
async render(): Promise<any> {
return this.renderer.render();
}
getMimeType(): string {
return this.mimeType;
}
finalize(): void {
this.db.close();
}
}
export {
HitCounter,
HitCounterConfig,
HitCounterRenderer,
BaseHitCounterRenderer,
ImageHitCounterRenderer
};
export default HitCounter;

33
node/src/index.ts Normal file
View file

@ -0,0 +1,33 @@
import express, { Express, Request, Response } from "express";
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) => {
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}`);
});