almost done, overengineered hit counter
This commit is contained in:
parent
2eda6e11a1
commit
dc932eded7
7 changed files with 2659 additions and 0 deletions
2
node/.gitignore
vendored
Normal file
2
node/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
.DS_Store
|
BIN
node/hits.db
Normal file
BIN
node/hits.db
Normal file
Binary file not shown.
BIN
node/hits.db-wal
Normal file
BIN
node/hits.db-wal
Normal file
Binary file not shown.
2322
node/package-lock.json
generated
Normal file
2322
node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
node/package.json
Normal file
26
node/package.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "hit_counter",
|
||||
"version": "0.0.1",
|
||||
"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 tsx src/index.ts"
|
||||
},
|
||||
"author": "zeyus",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.8.7",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
287
node/src/hit-counter.ts
Normal file
287
node/src/hit-counter.ts
Normal file
|
@ -0,0 +1,287 @@
|
|||
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 * as fs from 'fs';
|
||||
|
||||
// Map file extensions to sharp formats
|
||||
const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo } = {
|
||||
jpg: 'jpeg',
|
||||
jpeg: 'jpeg',
|
||||
png: 'png',
|
||||
webp: 'webp',
|
||||
tiff: 'tiff',
|
||||
gif: '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>;
|
||||
}
|
||||
|
||||
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> {
|
||||
// render the counter
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
||||
private imageType: string;
|
||||
private imageFormat: keyof FormatEnum | AvailableFormatInfo;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
private async cachedBackgroundImage(overWrite: boolean = false): Promise<Sharp> {
|
||||
// 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 fileExt = dest.split('.').pop();
|
||||
if (!fileExt) {
|
||||
throw new Error('Invalid file extension');
|
||||
}
|
||||
|
||||
const format = imageFormatMap[fileExt.toLowerCase()];
|
||||
if (!format) {
|
||||
throw new Error('Unsupported file extension');
|
||||
}
|
||||
|
||||
const image = sharp(this.config.backgroundImageUrl).toFormat(format);
|
||||
await image.toFile(dest);
|
||||
return image;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async render(): Promise<Sharp> {
|
||||
const hits = await this.counter.getHits();
|
||||
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 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"/>
|
||||
</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>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
|
||||
|
||||
const overlay = sharp(Buffer.from(svgOverlay));
|
||||
if (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 composite = bgImage.composite([
|
||||
{ input: await overlay.toBuffer(), gravity: 'northwest' }
|
||||
]);
|
||||
|
||||
return composite;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
renderer: Type<BaseHitCounterRenderer>; // Renderer class for the counter
|
||||
}
|
||||
|
||||
|
||||
|
||||
// default configuration
|
||||
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: 5,
|
||||
numberPositionX: 5,
|
||||
numberPositionY: 15,
|
||||
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 },
|
||||
drawFrame: true,
|
||||
renderer: ImageHitCounterRenderer
|
||||
};
|
||||
|
||||
class HitCounter {
|
||||
private db: DB;
|
||||
private renderer: BaseHitCounterRenderer;
|
||||
private config: HitCounterConfig;
|
||||
private preparedGet: Statement;
|
||||
private preparedIncrement: Statement;
|
||||
private preparedReset: Statement;
|
||||
|
||||
constructor(renderer: Type<BaseHitCounterRenderer>, config?: HitCounterConfig) {
|
||||
this.config = { ...defaultHitCounterConfig, ...config };
|
||||
this.renderer = new renderer(this, this.config);
|
||||
this.openDB();
|
||||
this.setupDB();
|
||||
this.setupStatements();
|
||||
}
|
||||
|
||||
private openDB(): void {
|
||||
const db = new Database(this.config.counterDB, {fileMustExist: false});
|
||||
db.pragma('locking_mode = EXCLUSIVE');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = OFF');
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
private setupDB(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS hit_counter (
|
||||
id INTEGER PRIMARY KEY,
|
||||
hits INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
this.db.exec(`
|
||||
INSERT OR IGNORE INTO hit_counter (id, hits)
|
||||
VALUES (?, ${this.config.siteId})
|
||||
`);
|
||||
}
|
||||
|
||||
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 = ?
|
||||
`);
|
||||
}
|
||||
|
||||
async getHits(): Promise<number> {
|
||||
const result = this.preparedGet.get(this.config.siteId) as HitResult;
|
||||
return result.hits;
|
||||
}
|
||||
|
||||
async increment(): Promise<void> {
|
||||
this.preparedIncrement.run(this.config.siteId);
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
this.preparedReset.run(this.config.siteId);
|
||||
}
|
||||
|
||||
// destructor
|
||||
destroy(): void {
|
||||
this.closeDB();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export {
|
||||
HitCounter,
|
||||
HitCounterConfig,
|
||||
HitCounterRenderer,
|
||||
BaseHitCounterRenderer,
|
||||
ImageHitCounterRenderer
|
||||
};
|
||||
|
||||
export default HitCounter;
|
22
node/src/index.ts
Normal file
22
node/src/index.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
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);
|
||||
|
||||
app.get("/", (req: Request, res: Response) => {
|
||||
res.send("Hello World!");
|
||||
hitCounter.increment();
|
||||
});
|
||||
|
||||
app.get("/hits", async (req: Request, res: Response) => {
|
||||
const hits = await hitCounter.getHits();
|
||||
res.send(`This site has ${hits} hits!`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[server]: Express server started on port ${PORT}`);
|
||||
});
|
Loading…
Reference in a new issue