2024-11-03 16:33:05 +00:00
|
|
|
import { type Database as DB } from 'better-sqlite3';
|
|
|
|
import { type Statement } from 'better-sqlite3';
|
|
|
|
import Database from 'better-sqlite3';
|
|
|
|
// import sharp for images
|
2024-11-06 16:21:05 +00:00
|
|
|
import sharp, { FormatEnum, AvailableFormatInfo, Sharp, Metadata, TextAlign } from 'sharp';
|
2024-11-03 16:33:05 +00:00
|
|
|
import * as fs from 'fs';
|
2024-11-03 20:58:43 +00:00
|
|
|
import * as https from 'https';
|
2024-11-03 16:33:05 +00:00
|
|
|
|
|
|
|
// 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'
|
|
|
|
};
|
|
|
|
|
2024-11-03 20:58:43 +00:00
|
|
|
// 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'
|
|
|
|
};
|
|
|
|
|
2024-11-03 16:33:05 +00:00
|
|
|
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>;
|
|
|
|
}
|
|
|
|
|
2024-11-03 20:58:43 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-03 16:33:05 +00:00
|
|
|
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;
|
2024-11-03 20:58:43 +00:00
|
|
|
private baseImage: Buffer;
|
|
|
|
private baseImageMetadata: Metadata;
|
2024-11-03 16:33:05 +00:00
|
|
|
constructor(counter: HitCounter, config: HitCounterConfig) {
|
|
|
|
super(counter, config);
|
2024-11-03 20:58:43 +00:00
|
|
|
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);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
2024-11-03 16:33:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
2024-11-03 20:58:43 +00:00
|
|
|
const ext = fileExt(this.config.backgroundImageUrl);
|
2024-11-03 16:33:05 +00:00
|
|
|
|
2024-11-03 20:58:43 +00:00
|
|
|
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');
|
|
|
|
}
|
2024-11-03 16:33:05 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-03 20:58:43 +00:00
|
|
|
async prepareBaseImage(): Promise<void> {
|
|
|
|
if (typeof this.baseImage !== 'undefined') {
|
|
|
|
return;
|
|
|
|
}
|
2024-11-03 16:33:05 +00:00
|
|
|
const bgImage = await this.cachedBackgroundImage();
|
|
|
|
const bgInfo = await bgImage.metadata();
|
2024-11-03 20:58:43 +00:00
|
|
|
const imgWidth = bgInfo.width;
|
|
|
|
const imgHeight = bgInfo.height;
|
2024-11-06 16:21:05 +00:00
|
|
|
const imgDensity = bgInfo.density;
|
|
|
|
if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined' || typeof imgDensity === 'undefined') {
|
2024-11-03 20:58:43 +00:00
|
|
|
throw new Error('Invalid image dimensions');
|
|
|
|
}
|
2024-11-03 16:33:05 +00:00
|
|
|
|
2024-11-06 16:21:05 +00:00
|
|
|
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}" />`;
|
|
|
|
|
|
|
|
}
|
2024-11-03 16:33:05 +00:00
|
|
|
|
|
|
|
const svgOverlay = `
|
2024-11-06 16:21:05 +00:00
|
|
|
<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}
|
2024-11-03 16:33:05 +00:00
|
|
|
</svg>
|
|
|
|
`;
|
|
|
|
|
2024-11-06 16:21:05 +00:00
|
|
|
const overlay = Buffer.from(svgOverlay);
|
2024-11-03 16:33:05 +00:00
|
|
|
|
|
|
|
const composite = bgImage.composite([
|
2024-11-06 16:21:05 +00:00
|
|
|
{
|
|
|
|
input: overlay,
|
|
|
|
top: 0,
|
|
|
|
left: 0
|
|
|
|
}
|
2024-11-03 16:33:05 +00:00
|
|
|
]);
|
|
|
|
|
2024-11-06 16:21:05 +00:00
|
|
|
const preparedImg = composite.toFormat(this.imageFormat).withMetadata();
|
2024-11-03 20:58:43 +00:00
|
|
|
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();
|
2024-11-06 16:21:05 +00:00
|
|
|
|
|
|
|
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>
|
2024-11-03 20:58:43 +00:00
|
|
|
</svg>
|
|
|
|
`;
|
2024-11-06 16:21:05 +00:00
|
|
|
|
|
|
|
const numberOverlay = Buffer.from(svgNumber);
|
|
|
|
|
2024-11-03 20:58:43 +00:00
|
|
|
const composite = sharp(this.baseImage).composite([
|
2024-11-06 16:21:05 +00:00
|
|
|
{
|
|
|
|
input: numberOverlay,
|
|
|
|
top: 0,
|
|
|
|
left: 0
|
|
|
|
}
|
2024-11-03 20:58:43 +00:00
|
|
|
]);
|
2024-11-06 16:21:05 +00:00
|
|
|
|
2024-11-03 20:58:43 +00:00
|
|
|
return composite.toBuffer();
|
|
|
|
|
2024-11-03 16:33:05 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2024-11-03 20:58:43 +00:00
|
|
|
fontSize: number; // Font size
|
|
|
|
fontFace: string; // Font face
|
|
|
|
borderWidth: number; // Border width
|
2024-11-03 16:33:05 +00:00
|
|
|
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,
|
2024-11-06 16:21:05 +00:00
|
|
|
textPositionY: 11,
|
|
|
|
numberPositionX: 170,
|
|
|
|
numberPositionY: 18,
|
|
|
|
fontSize: 10,
|
|
|
|
fontFace: 'fixed',
|
2024-11-03 20:58:43 +00:00
|
|
|
borderWidth: 3,
|
2024-11-03 16:33:05 +00:00
|
|
|
textColorRGB: { r: 253, g: 252, b: 1 },
|
|
|
|
secondaryTextColorRGB: { r: 0, g: 255, b: 0 },
|
|
|
|
numberColorRGB: { r: 255, g: 255, b: 255 },
|
2024-11-03 20:58:43 +00:00
|
|
|
frameColorRGB: { r: 255, g: 0, b: 0 },
|
2024-11-03 16:33:05 +00:00
|
|
|
drawFrame: true,
|
|
|
|
renderer: ImageHitCounterRenderer
|
|
|
|
};
|
|
|
|
|
|
|
|
class HitCounter {
|
|
|
|
private db: DB;
|
|
|
|
private renderer: BaseHitCounterRenderer;
|
|
|
|
private config: HitCounterConfig;
|
|
|
|
private preparedGet: Statement;
|
|
|
|
private preparedIncrement: Statement;
|
|
|
|
private preparedReset: Statement;
|
2024-11-03 20:58:43 +00:00
|
|
|
private mimeType: string;
|
2024-11-03 16:33:05 +00:00
|
|
|
|
|
|
|
constructor(renderer: Type<BaseHitCounterRenderer>, config?: HitCounterConfig) {
|
|
|
|
this.config = { ...defaultHitCounterConfig, ...config };
|
|
|
|
this.renderer = new renderer(this, this.config);
|
|
|
|
this.openDB();
|
|
|
|
this.setupDB();
|
|
|
|
this.setupStatements();
|
2024-11-03 20:58:43 +00:00
|
|
|
this.mimeType = mimeTypeMap[fileExt(this.config.imageFile)];
|
2024-11-03 16:33:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2024-11-03 20:58:43 +00:00
|
|
|
async render(): Promise<any> {
|
|
|
|
return this.renderer.render();
|
|
|
|
}
|
|
|
|
|
|
|
|
getMimeType(): string {
|
|
|
|
return this.mimeType;
|
|
|
|
}
|
|
|
|
|
2024-11-03 16:33:05 +00:00
|
|
|
// destructor
|
|
|
|
destroy(): void {
|
|
|
|
this.closeDB();
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export {
|
|
|
|
HitCounter,
|
|
|
|
HitCounterConfig,
|
|
|
|
HitCounterRenderer,
|
|
|
|
BaseHitCounterRenderer,
|
|
|
|
ImageHitCounterRenderer
|
|
|
|
};
|
|
|
|
|
|
|
|
export default HitCounter;
|