feature/nodejs_implementation #1
7 changed files with 3286 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
|
147
node/README.md
Normal file
147
node/README.md
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
# 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** in your PHP configuration (`php.ini`):
|
||||||
|
|
||||||
|
```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";
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
const hitCounter = new HitCounter(ImageHitCounterRenderer, defaultHitCounterConfig);
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
400
node/src/hit-counter.ts
Normal file
400
node/src/hit-counter.ts
Normal file
|
@ -0,0 +1,400 @@
|
||||||
|
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, Metadata, TextAlign } 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 } = {
|
||||||
|
jpg: 'jpeg',
|
||||||
|
jpeg: 'jpeg',
|
||||||
|
png: 'png',
|
||||||
|
webp: 'webp',
|
||||||
|
tiff: 'tiff',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGBColor {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Type<T> extends Function { new (...args: any[]): T; }
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
private baseImage: Buffer;
|
||||||
|
private baseImageMetadata: Metadata;
|
||||||
|
constructor(counter: HitCounter, config: HitCounterConfig) {
|
||||||
|
super(counter, config);
|
||||||
|
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> {
|
||||||
|
// 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 ext = fileExt(this.config.backgroundImageUrl);
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareBaseImage(): Promise<void> {
|
||||||
|
if (typeof this.baseImage !== 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bgImage = await this.cachedBackgroundImage();
|
||||||
|
const bgInfo = await bgImage.metadata();
|
||||||
|
const imgWidth = bgInfo.width;
|
||||||
|
const imgHeight = bgInfo.height;
|
||||||
|
const imgDensity = bgInfo.density;
|
||||||
|
if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined' || typeof imgDensity === 'undefined') {
|
||||||
|
throw new Error('Invalid image dimensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
let svgFrame = '';
|
||||||
|
if (this.config.drawFrame) {
|
||||||
|
svgFrame = `<rect x="0" y="0" width="100%" height="100%" fill="none" stroke-alignment="inner" stroke="rgb(${this.config.frameColorRGB.r},${this.config.frameColorRGB.g},${this.config.frameColorRGB.b})" stroke-width="${this.config.borderWidth}" />`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgOverlay = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
|
||||||
|
<text y="${this.config.textPositionY}" font-family="${this.config.fontFace}" font-size="${this.config.fontSize}">
|
||||||
|
<tspan x="${this.config.textPositionX}" dy="0" fill="rgb(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})">${this.config.customText}</tspan>
|
||||||
|
<tspan x="${this.config.textPositionX}" dy="1.2em" fill="rgb(${this.config.secondaryTextColorRGB.r},${this.config.secondaryTextColorRGB.g},${this.config.secondaryTextColorRGB.b})">${this.config.secondaryText}</tspan>
|
||||||
|
</text>
|
||||||
|
${svgFrame}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const overlay = Buffer.from(svgOverlay);
|
||||||
|
|
||||||
|
const composite = bgImage.composite([
|
||||||
|
{
|
||||||
|
input: overlay,
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const preparedImg = composite.toFormat(this.imageFormat).withMetadata();
|
||||||
|
this.baseImageMetadata = await preparedImg.metadata();
|
||||||
|
this.baseImage = await preparedImg.toBuffer();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(): Promise<Buffer> {
|
||||||
|
const hits = await this.counter.getHits();
|
||||||
|
|
||||||
|
const numberX = this.config.numberPositionX;
|
||||||
|
const numberY = this.config.numberPositionY;
|
||||||
|
|
||||||
|
await this.prepareBaseImage();
|
||||||
|
|
||||||
|
const imgWidth = this.baseImageMetadata.width;
|
||||||
|
const imgHeight = this.baseImageMetadata.height;
|
||||||
|
if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined') {
|
||||||
|
throw new Error('Invalid image dimensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgNumber = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
|
||||||
|
<text x="${numberX}" y="${numberY}" font-family="${this.config.fontFace}" font-size="${this.config.fontSize}" text-anchor="end" fill="rgb(${this.config.numberColorRGB.r},${this.config.numberColorRGB.g},${this.config.numberColorRGB.b})">${hits}</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const numberOverlay = Buffer.from(svgNumber);
|
||||||
|
|
||||||
|
const composite = sharp(this.baseImage).composite([
|
||||||
|
{
|
||||||
|
input: numberOverlay,
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
return composite.toBuffer();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
class HitCounter {
|
||||||
|
private db: DB;
|
||||||
|
private renderer: BaseHitCounterRenderer;
|
||||||
|
private config: HitCounterConfig;
|
||||||
|
private preparedGet: Statement;
|
||||||
|
private preparedIncrement: Statement;
|
||||||
|
private preparedReset: Statement;
|
||||||
|
private mimeType: string;
|
||||||
|
|
||||||
|
constructor(renderer: Type<BaseHitCounterRenderer>, config?: HitCounterConfig) {
|
||||||
|
this.config = { ...defaultHitCounterConfig, ...config };
|
||||||
|
this.renderer = new renderer(this, this.config);
|
||||||
|
this.openDB();
|
||||||
|
this.setupDB();
|
||||||
|
this.setupStatements();
|
||||||
|
this.mimeType = mimeTypeMap[fileExt(this.config.imageFile)];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(): Promise<any> {
|
||||||
|
return this.renderer.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
getMimeType(): string {
|
||||||
|
return this.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// destructor
|
||||||
|
destroy(): void {
|
||||||
|
this.closeDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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, 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}`);
|
||||||
|
});
|
Loading…
Reference in a new issue