tailprint 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/dist/config/env.d.ts +77 -0
- package/dist/config/env.js +38 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/plans.d.ts +15 -0
- package/dist/config/plans.js +104 -0
- package/dist/config/plans.js.map +1 -0
- package/dist/config/templates.d.ts +10 -0
- package/dist/config/templates.js +746 -0
- package/dist/config/templates.js.map +1 -0
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +897 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio.d.ts +2 -0
- package/dist/mcp/stdio.js +27 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/routes/dashboard.d.ts +2 -0
- package/dist/routes/dashboard.js +454 -0
- package/dist/routes/dashboard.js.map +1 -0
- package/dist/routes/mcp.d.ts +2 -0
- package/dist/routes/mcp.js +329 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/render.d.ts +2 -0
- package/dist/routes/render.js +444 -0
- package/dist/routes/render.js.map +1 -0
- package/dist/routes/scrape.d.ts +2 -0
- package/dist/routes/scrape.js +132 -0
- package/dist/routes/scrape.js.map +1 -0
- package/dist/routes/templates.d.ts +2 -0
- package/dist/routes/templates.js +2449 -0
- package/dist/routes/templates.js.map +1 -0
- package/dist/routes/usage.d.ts +31 -0
- package/dist/routes/usage.js +73 -0
- package/dist/routes/usage.js.map +1 -0
- package/dist/routes/webhooks.d.ts +2 -0
- package/dist/routes/webhooks.js +227 -0
- package/dist/routes/webhooks.js.map +1 -0
- package/dist/schemas/render.d.ts +422 -0
- package/dist/schemas/render.js +91 -0
- package/dist/schemas/render.js.map +1 -0
- package/dist/schemas/scrape.d.ts +65 -0
- package/dist/schemas/scrape.js +24 -0
- package/dist/schemas/scrape.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +206 -0
- package/dist/server.js.map +1 -0
- package/dist/services/browser.d.ts +21 -0
- package/dist/services/browser.js +299 -0
- package/dist/services/browser.js.map +1 -0
- package/dist/services/carousel.d.ts +43 -0
- package/dist/services/carousel.js +300 -0
- package/dist/services/carousel.js.map +1 -0
- package/dist/services/chart.d.ts +29 -0
- package/dist/services/chart.js +129 -0
- package/dist/services/chart.js.map +1 -0
- package/dist/services/eInvoice.d.ts +62 -0
- package/dist/services/eInvoice.js +413 -0
- package/dist/services/eInvoice.js.map +1 -0
- package/dist/services/email.d.ts +24 -0
- package/dist/services/email.js +177 -0
- package/dist/services/email.js.map +1 -0
- package/dist/services/mockup.d.ts +14 -0
- package/dist/services/mockup.js +130 -0
- package/dist/services/mockup.js.map +1 -0
- package/dist/services/pdfStamper.d.ts +16 -0
- package/dist/services/pdfStamper.js +47 -0
- package/dist/services/pdfStamper.js.map +1 -0
- package/dist/services/qr.d.ts +20 -0
- package/dist/services/qr.js +44 -0
- package/dist/services/qr.js.map +1 -0
- package/dist/services/redis.d.ts +65 -0
- package/dist/services/redis.js +318 -0
- package/dist/services/redis.js.map +1 -0
- package/dist/services/scraper.d.ts +16 -0
- package/dist/services/scraper.js +138 -0
- package/dist/services/scraper.js.map +1 -0
- package/dist/services/storage.d.ts +38 -0
- package/dist/services/storage.js +123 -0
- package/dist/services/storage.js.map +1 -0
- package/dist/services/stripe.d.ts +32 -0
- package/dist/services/stripe.js +229 -0
- package/dist/services/stripe.js.map +1 -0
- package/dist/services/visualDiff.d.ts +33 -0
- package/dist/services/visualDiff.js +218 -0
- package/dist/services/visualDiff.js.map +1 -0
- package/package.json +74 -4
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DeviceMockupOptions {
|
|
2
|
+
url: string;
|
|
3
|
+
title?: string;
|
|
4
|
+
delay_ms?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class MockupService {
|
|
7
|
+
private static instance;
|
|
8
|
+
static getInstance(): MockupService;
|
|
9
|
+
/**
|
|
10
|
+
* Captures a website and composites it into a realistic Laptop + Phone device mockup.
|
|
11
|
+
*/
|
|
12
|
+
renderDeviceMockup(options: DeviceMockupOptions): Promise<Buffer>;
|
|
13
|
+
}
|
|
14
|
+
export declare const mockupService: MockupService;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { browserService } from './browser.js';
|
|
2
|
+
export class MockupService {
|
|
3
|
+
static instance = null;
|
|
4
|
+
static getInstance() {
|
|
5
|
+
if (!MockupService.instance) {
|
|
6
|
+
MockupService.instance = new MockupService();
|
|
7
|
+
}
|
|
8
|
+
return MockupService.instance;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Captures a website and composites it into a realistic Laptop + Phone device mockup.
|
|
12
|
+
*/
|
|
13
|
+
async renderDeviceMockup(options) {
|
|
14
|
+
const { url, title, delay_ms = 500 } = options;
|
|
15
|
+
// 1. Capture Desktop Viewport (1280x800)
|
|
16
|
+
const desktopBuffer = await browserService.renderImage({
|
|
17
|
+
url,
|
|
18
|
+
width: 1280,
|
|
19
|
+
height: 800,
|
|
20
|
+
type: 'png',
|
|
21
|
+
delay_ms,
|
|
22
|
+
});
|
|
23
|
+
// 2. Capture Mobile Viewport (390x844)
|
|
24
|
+
const mobileBuffer = await browserService.renderImage({
|
|
25
|
+
url,
|
|
26
|
+
width: 390,
|
|
27
|
+
height: 844,
|
|
28
|
+
type: 'png',
|
|
29
|
+
delay_ms,
|
|
30
|
+
});
|
|
31
|
+
const desktopDataUri = `data:image/png;base64,${desktopBuffer.toString('base64')}`;
|
|
32
|
+
const mobileDataUri = `data:image/png;base64,${mobileBuffer.toString('base64')}`;
|
|
33
|
+
let parsedDomain = url;
|
|
34
|
+
try {
|
|
35
|
+
parsedDomain = new URL(url).hostname;
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
const html = `<!DOCTYPE html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="UTF-8">
|
|
42
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
43
|
+
<style>
|
|
44
|
+
* { box-sizing: border-box; }
|
|
45
|
+
body {
|
|
46
|
+
margin: 0;
|
|
47
|
+
padding: 0;
|
|
48
|
+
width: 1600px;
|
|
49
|
+
height: 1000px;
|
|
50
|
+
background: radial-gradient(circle at 50% 30%, #1e1b4b 0%, #0f172a 50%, #020617 100%);
|
|
51
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
justify-content: space-between;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
position: relative;
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body class="p-12 text-white">
|
|
61
|
+
|
|
62
|
+
<!-- Top Info Badge -->
|
|
63
|
+
<div class="flex items-center justify-between z-10">
|
|
64
|
+
<div class="flex items-center space-x-3">
|
|
65
|
+
<div class="px-4 py-1.5 rounded-full bg-slate-900/80 border border-slate-700/80 backdrop-blur-md flex items-center space-x-2 text-xs font-mono text-slate-300 shadow-xl">
|
|
66
|
+
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
|
67
|
+
<span>${parsedDomain}</span>
|
|
68
|
+
</div>
|
|
69
|
+
${title
|
|
70
|
+
? `<span class="text-sm font-bold text-slate-300 tracking-tight">${title}</span>`
|
|
71
|
+
: ''}
|
|
72
|
+
</div>
|
|
73
|
+
<div class="text-xs font-mono text-slate-500">
|
|
74
|
+
Multi-Device Responsive Mockup • Tailprint Engine
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Devices Stage Container -->
|
|
79
|
+
<div class="relative w-full h-[820px] flex items-center justify-center">
|
|
80
|
+
|
|
81
|
+
<!-- Desktop / Laptop Frame -->
|
|
82
|
+
<div class="absolute left-10 bottom-6 w-[1050px] shadow-2xl shadow-black/90 z-10 transition">
|
|
83
|
+
<!-- Laptop Screen Shell -->
|
|
84
|
+
<div class="rounded-t-2xl bg-slate-900 p-3 border-t-2 border-x-2 border-slate-700/80 shadow-inner">
|
|
85
|
+
<!-- Top Screen Bezel with Camera -->
|
|
86
|
+
<div class="flex items-center justify-center pb-2">
|
|
87
|
+
<div class="w-2 h-2 rounded-full bg-slate-800 border border-slate-700"></div>
|
|
88
|
+
</div>
|
|
89
|
+
<!-- Screen Content Container -->
|
|
90
|
+
<div class="w-full h-[580px] rounded-lg overflow-hidden bg-slate-950 border border-slate-800">
|
|
91
|
+
<img src="${desktopDataUri}" class="w-full h-full object-cover object-top" />
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<!-- Laptop Base / Keyboard Deck -->
|
|
95
|
+
<div class="relative h-4 bg-gradient-to-b from-slate-700 to-slate-800 rounded-b-xl border-t border-slate-600 shadow-lg flex justify-center">
|
|
96
|
+
<div class="w-24 h-1.5 bg-slate-900 rounded-b-md"></div>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="h-1 bg-slate-900/50 rounded-b-full mx-6 blur-[1px]"></div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<!-- Mobile / iPhone Floating Frame (Overlapping Bottom Right) -->
|
|
102
|
+
<div class="absolute right-14 bottom-12 w-[340px] h-[700px] rounded-[48px] p-3 bg-slate-900 border-4 border-slate-700 shadow-2xl shadow-black z-20 flex flex-col justify-between">
|
|
103
|
+
<!-- Outer Antenna Lines & Bezel -->
|
|
104
|
+
<div class="relative w-full h-full rounded-[40px] overflow-hidden bg-black border border-slate-800">
|
|
105
|
+
<!-- Dynamic Island Notch -->
|
|
106
|
+
<div class="absolute top-2.5 left-1/2 -translate-x-1/2 w-24 h-6 bg-black rounded-full z-30 flex items-center justify-end px-2">
|
|
107
|
+
<div class="w-2.5 h-2.5 rounded-full bg-slate-900/90 border border-slate-800"></div>
|
|
108
|
+
</div>
|
|
109
|
+
<!-- Mobile Screen Content -->
|
|
110
|
+
<img src="${mobileDataUri}" class="w-full h-full object-cover object-top" />
|
|
111
|
+
<!-- Bottom Home Indicator -->
|
|
112
|
+
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1 bg-white/40 rounded-full z-30"></div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
</body>
|
|
119
|
+
</html>`;
|
|
120
|
+
return browserService.renderImage({
|
|
121
|
+
html,
|
|
122
|
+
width: 1600,
|
|
123
|
+
height: 1000,
|
|
124
|
+
type: 'png',
|
|
125
|
+
delay_ms: 200,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export const mockupService = MockupService.getInstance();
|
|
130
|
+
//# sourceMappingURL=mockup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mockup.js","sourceRoot":"","sources":["../../src/services/mockup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAQ9C,MAAM,OAAO,aAAa;IAChB,MAAM,CAAC,QAAQ,GAAyB,IAAI,CAAC;IAE9C,MAAM,CAAC,WAAW;QACvB,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,CAAC;YAC5B,aAAa,CAAC,QAAQ,GAAG,IAAI,aAAa,EAAE,CAAC;QAC/C,CAAC;QACD,OAAO,aAAa,CAAC,QAAQ,CAAC;IAChC,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,kBAAkB,CAAC,OAA4B;QAC1D,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,QAAQ,GAAG,GAAG,EAAE,GAAG,OAAO,CAAC;QAE/C,yCAAyC;QACzC,MAAM,aAAa,GAAG,MAAM,cAAc,CAAC,WAAW,CAAC;YACrD,GAAG;YACH,KAAK,EAAE,IAAI;YACX,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,KAAK;YACX,QAAQ;SACT,CAAC,CAAC;QAEH,uCAAuC;QACvC,MAAM,YAAY,GAAG,MAAM,cAAc,CAAC,WAAW,CAAC;YACpD,GAAG;YACH,KAAK,EAAE,GAAG;YACV,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,KAAK;YACX,QAAQ;SACT,CAAC,CAAC;QAEH,MAAM,cAAc,GAAG,yBAAyB,aAAa,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACnF,MAAM,aAAa,GAAG,yBAAyB,YAAY,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAEjF,IAAI,YAAY,GAAG,GAAG,CAAC;QACvB,IAAI,CAAC;YACH,YAAY,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC;QACvC,CAAC;QAAC,MAAM,CAAC,CAAA,CAAC;QAEV,MAAM,IAAI,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBA6BD,YAAY;;QAGpB,KAAK;YACH,CAAC,CAAC,iEAAiE,KAAK,SAAS;YACjF,CAAC,CAAC,EACN;;;;;;;;;;;;;;;;;;;;sBAoBgB,cAAc;;;;;;;;;;;;;;;;;;;oBAmBhB,aAAa;;;;;;;;;QASzB,CAAC;QAEL,OAAO,cAAc,CAAC,WAAW,CAAC;YAChC,IAAI;YACJ,KAAK,EAAE,IAAI;YACX,MAAM,EAAE,IAAI;YACZ,IAAI,EAAE,KAAK;YACX,QAAQ,EAAE,GAAG;SACd,CAAC,CAAC;IACL,CAAC;;AAGH,MAAM,CAAC,MAAM,aAAa,GAAG,aAAa,CAAC,WAAW,EAAE,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface WatermarkOptions {
|
|
2
|
+
pdfBuffer: Buffer;
|
|
3
|
+
text: string;
|
|
4
|
+
opacity?: number;
|
|
5
|
+
color?: 'red' | 'gray' | 'blue';
|
|
6
|
+
fontSize?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare class PdfStamperService {
|
|
9
|
+
private static instance;
|
|
10
|
+
static getInstance(): PdfStamperService;
|
|
11
|
+
/**
|
|
12
|
+
* Applies a diagonal, semi-transparent watermark across all pages of a PDF document.
|
|
13
|
+
*/
|
|
14
|
+
applyWatermark(options: WatermarkOptions): Promise<Buffer>;
|
|
15
|
+
}
|
|
16
|
+
export declare const pdfStamperService: PdfStamperService;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { PDFDocument, rgb, degrees, StandardFonts } from 'pdf-lib';
|
|
2
|
+
export class PdfStamperService {
|
|
3
|
+
static instance = null;
|
|
4
|
+
static getInstance() {
|
|
5
|
+
if (!PdfStamperService.instance) {
|
|
6
|
+
PdfStamperService.instance = new PdfStamperService();
|
|
7
|
+
}
|
|
8
|
+
return PdfStamperService.instance;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Applies a diagonal, semi-transparent watermark across all pages of a PDF document.
|
|
12
|
+
*/
|
|
13
|
+
async applyWatermark(options) {
|
|
14
|
+
const { pdfBuffer, text, opacity = 0.15, color = 'red', fontSize = 54, } = options;
|
|
15
|
+
const pdfDoc = await PDFDocument.load(pdfBuffer, { ignoreEncryption: true });
|
|
16
|
+
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
17
|
+
const pages = pdfDoc.getPages();
|
|
18
|
+
let colorRgb = rgb(0.85, 0.1, 0.1); // default red
|
|
19
|
+
if (color === 'gray') {
|
|
20
|
+
colorRgb = rgb(0.4, 0.4, 0.4);
|
|
21
|
+
}
|
|
22
|
+
else if (color === 'blue') {
|
|
23
|
+
colorRgb = rgb(0.1, 0.3, 0.85);
|
|
24
|
+
}
|
|
25
|
+
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
|
26
|
+
const textHeight = font.heightAtSize(fontSize);
|
|
27
|
+
for (const page of pages) {
|
|
28
|
+
const { width, height } = page.getSize();
|
|
29
|
+
// Center position adjusted for 45-degree rotation
|
|
30
|
+
const x = width / 2 - (textWidth / 2) * Math.cos(Math.PI / 4);
|
|
31
|
+
const y = height / 2 - (textWidth / 2) * Math.sin(Math.PI / 4) + textHeight;
|
|
32
|
+
page.drawText(text, {
|
|
33
|
+
x: Math.max(20, x),
|
|
34
|
+
y: Math.max(20, y),
|
|
35
|
+
size: fontSize,
|
|
36
|
+
font,
|
|
37
|
+
color: colorRgb,
|
|
38
|
+
opacity: Math.min(Math.max(opacity, 0.05), 0.8),
|
|
39
|
+
rotate: degrees(45),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const modifiedBytes = await pdfDoc.save();
|
|
43
|
+
return Buffer.from(modifiedBytes);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const pdfStamperService = PdfStamperService.getInstance();
|
|
47
|
+
//# sourceMappingURL=pdfStamper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pdfStamper.js","sourceRoot":"","sources":["../../src/services/pdfStamper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAUnE,MAAM,OAAO,iBAAiB;IACpB,MAAM,CAAC,QAAQ,GAA6B,IAAI,CAAC;IAElD,MAAM,CAAC,WAAW;QACvB,IAAI,CAAC,iBAAiB,CAAC,QAAQ,EAAE,CAAC;YAChC,iBAAiB,CAAC,QAAQ,GAAG,IAAI,iBAAiB,EAAE,CAAC;QACvD,CAAC;QACD,OAAO,iBAAiB,CAAC,QAAQ,CAAC;IACpC,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,cAAc,CAAC,OAAyB;QACnD,MAAM,EACJ,SAAS,EACT,IAAI,EACJ,OAAO,GAAG,IAAI,EACd,KAAK,GAAG,KAAK,EACb,QAAQ,GAAG,EAAE,GACd,GAAG,OAAO,CAAC;QAEZ,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC,CAAC;QAC7E,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,aAAa,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAC;QAEhC,IAAI,QAAQ,GAAG,GAAG,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,cAAc;QAClD,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;YACrB,QAAQ,GAAG,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;QAChC,CAAC;aAAM,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;YAC5B,QAAQ,GAAG,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;QACjC,CAAC;QAED,MAAM,SAAS,GAAG,IAAI,CAAC,iBAAiB,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QACzD,MAAM,UAAU,GAAG,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;QAE/C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;YACzC,kDAAkD;YAClD,MAAM,CAAC,GAAG,KAAK,GAAG,CAAC,GAAG,CAAC,SAAS,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;YAC9D,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC,GAAG,CAAC,SAAS,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,UAAU,CAAC;YAE5E,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE;gBAClB,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;gBAClB,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;gBAClB,IAAI,EAAE,QAAQ;gBACd,IAAI;gBACJ,KAAK,EAAE,QAAQ;gBACf,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC;gBAC/C,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;aACpB,CAAC,CAAC;QACL,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAC1C,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IACpC,CAAC;;AAGH,MAAM,CAAC,MAAM,iBAAiB,GAAG,iBAAiB,CAAC,WAAW,EAAE,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface GenerateQrOptions {
|
|
2
|
+
text: string;
|
|
3
|
+
size?: number;
|
|
4
|
+
darkColor?: string;
|
|
5
|
+
lightColor?: string;
|
|
6
|
+
errorCorrection?: 'L' | 'M' | 'Q' | 'H';
|
|
7
|
+
}
|
|
8
|
+
export declare class QrService {
|
|
9
|
+
private static instance;
|
|
10
|
+
static getInstance(): QrService;
|
|
11
|
+
/**
|
|
12
|
+
* Generates a high-resolution, scannable PNG QR code buffer.
|
|
13
|
+
*/
|
|
14
|
+
generatePngBuffer(options: GenerateQrOptions): Promise<Buffer>;
|
|
15
|
+
/**
|
|
16
|
+
* Generates an SVG string of the QR code.
|
|
17
|
+
*/
|
|
18
|
+
generateSvg(options: GenerateQrOptions): Promise<string>;
|
|
19
|
+
}
|
|
20
|
+
export declare const qrService: QrService;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
2
|
+
export class QrService {
|
|
3
|
+
static instance = null;
|
|
4
|
+
static getInstance() {
|
|
5
|
+
if (!QrService.instance) {
|
|
6
|
+
QrService.instance = new QrService();
|
|
7
|
+
}
|
|
8
|
+
return QrService.instance;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generates a high-resolution, scannable PNG QR code buffer.
|
|
12
|
+
*/
|
|
13
|
+
async generatePngBuffer(options) {
|
|
14
|
+
const { text, size = 512, darkColor = '#000000', lightColor = '#ffffff', errorCorrection = 'M', } = options;
|
|
15
|
+
return QRCode.toBuffer(text, {
|
|
16
|
+
type: 'png',
|
|
17
|
+
width: size,
|
|
18
|
+
margin: 2,
|
|
19
|
+
color: {
|
|
20
|
+
dark: darkColor,
|
|
21
|
+
light: lightColor,
|
|
22
|
+
},
|
|
23
|
+
errorCorrectionLevel: errorCorrection,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generates an SVG string of the QR code.
|
|
28
|
+
*/
|
|
29
|
+
async generateSvg(options) {
|
|
30
|
+
const { text, size = 512, darkColor = '#000000', lightColor = '#ffffff', errorCorrection = 'M', } = options;
|
|
31
|
+
return QRCode.toString(text, {
|
|
32
|
+
type: 'svg',
|
|
33
|
+
width: size,
|
|
34
|
+
margin: 2,
|
|
35
|
+
color: {
|
|
36
|
+
dark: darkColor,
|
|
37
|
+
light: lightColor,
|
|
38
|
+
},
|
|
39
|
+
errorCorrectionLevel: errorCorrection,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const qrService = QrService.getInstance();
|
|
44
|
+
//# sourceMappingURL=qr.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qr.js","sourceRoot":"","sources":["../../src/services/qr.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,QAAQ,CAAC;AAU5B,MAAM,OAAO,SAAS;IACZ,MAAM,CAAC,QAAQ,GAAqB,IAAI,CAAC;IAE1C,MAAM,CAAC,WAAW;QACvB,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC;YACxB,SAAS,CAAC,QAAQ,GAAG,IAAI,SAAS,EAAE,CAAC;QACvC,CAAC;QACD,OAAO,SAAS,CAAC,QAAQ,CAAC;IAC5B,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,iBAAiB,CAAC,OAA0B;QACvD,MAAM,EACJ,IAAI,EACJ,IAAI,GAAG,GAAG,EACV,SAAS,GAAG,SAAS,EACrB,UAAU,GAAG,SAAS,EACtB,eAAe,GAAG,GAAG,GACtB,GAAG,OAAO,CAAC;QAEZ,OAAO,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;YAC3B,IAAI,EAAE,KAAK;YACX,KAAK,EAAE,IAAI;YACX,MAAM,EAAE,CAAC;YACT,KAAK,EAAE;gBACL,IAAI,EAAE,SAAS;gBACf,KAAK,EAAE,UAAU;aAClB;YACD,oBAAoB,EAAE,eAAe;SACtC,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,WAAW,CAAC,OAA0B;QACjD,MAAM,EACJ,IAAI,EACJ,IAAI,GAAG,GAAG,EACV,SAAS,GAAG,SAAS,EACrB,UAAU,GAAG,SAAS,EACtB,eAAe,GAAG,GAAG,GACtB,GAAG,OAAO,CAAC;QAEZ,OAAO,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;YAC3B,IAAI,EAAE,KAAK;YACX,KAAK,EAAE,IAAI;YACX,MAAM,EAAE,CAAC;YACT,KAAK,EAAE;gBACL,IAAI,EAAE,SAAS;gBACf,KAAK,EAAE,UAAU;aAClB;YACD,oBAAoB,EAAE,eAAe;SACtC,CAAC,CAAC;IACL,CAAC;;AAGH,MAAM,CAAC,MAAM,SAAS,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface ApiKeyRecord {
|
|
2
|
+
key_prefix: string;
|
|
3
|
+
customer_id: string;
|
|
4
|
+
plan: string;
|
|
5
|
+
monthly_limit: number;
|
|
6
|
+
status: 'active' | 'inactive';
|
|
7
|
+
created_at: string;
|
|
8
|
+
}
|
|
9
|
+
export interface QuotaCheckResult {
|
|
10
|
+
allowed: boolean;
|
|
11
|
+
currentUsage: number;
|
|
12
|
+
limit: number;
|
|
13
|
+
remaining: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class RedisService {
|
|
16
|
+
private static instance;
|
|
17
|
+
private client;
|
|
18
|
+
readonly isMemoryFallback: boolean;
|
|
19
|
+
private constructor();
|
|
20
|
+
static getInstance(): RedisService;
|
|
21
|
+
hashKey(rawKey: string): string;
|
|
22
|
+
private seedDevKey;
|
|
23
|
+
getApiKey(rawKey: string): Promise<ApiKeyRecord | null>;
|
|
24
|
+
storeApiKey(rawKey: string, record: ApiKeyRecord): Promise<void>;
|
|
25
|
+
updateKeyStatusByCustomer(customerId: string, status: 'active' | 'inactive'): Promise<number>;
|
|
26
|
+
updateCustomerPlan(customerId: string, plan: string, monthlyLimit: number): Promise<number>;
|
|
27
|
+
getMonthString(date?: Date): string;
|
|
28
|
+
incrementAndCheckQuota(rawKey: string, monthlyLimit: number): Promise<QuotaCheckResult>;
|
|
29
|
+
resetMonthlyQuotaForCustomer(customerId: string): Promise<void>;
|
|
30
|
+
generateImageCacheHash(params: {
|
|
31
|
+
html?: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
type: string;
|
|
36
|
+
wait_for?: string;
|
|
37
|
+
delay_ms?: number;
|
|
38
|
+
}): string;
|
|
39
|
+
getCachedImage(cacheHash: string): Promise<Buffer | null>;
|
|
40
|
+
setCachedImage(cacheHash: string, imageBuffer: Buffer, ttlSeconds?: number): Promise<void>;
|
|
41
|
+
generateScrapeCacheHash(params: {
|
|
42
|
+
url: string;
|
|
43
|
+
strip_images: boolean;
|
|
44
|
+
}): string;
|
|
45
|
+
getCachedScrape<T = any>(cacheHash: string): Promise<T | null>;
|
|
46
|
+
setCachedScrape(cacheHash: string, result: any, ttlSeconds?: number): Promise<void>;
|
|
47
|
+
getCurrentUsage(rawKey: string): Promise<number>;
|
|
48
|
+
saveSessionKey(sessionId: string, apiKey: string, plan: string, monthlyLimit: number, ttlSeconds?: number): Promise<void>;
|
|
49
|
+
getSessionKey(sessionId: string): Promise<{
|
|
50
|
+
apiKey: string;
|
|
51
|
+
plan: string;
|
|
52
|
+
monthlyLimit: number;
|
|
53
|
+
} | null>;
|
|
54
|
+
checkPlaygroundRateLimit(ip: string, maxPerHour?: number): Promise<{
|
|
55
|
+
allowed: boolean;
|
|
56
|
+
remaining: number;
|
|
57
|
+
}>;
|
|
58
|
+
getFreeKeyByEmail(email: string): Promise<string | null>;
|
|
59
|
+
setFreeKeyEmail(email: string, rawApiKey: string): Promise<void>;
|
|
60
|
+
checkClaimRateLimit(ip: string, maxPerHour?: number): Promise<{
|
|
61
|
+
allowed: boolean;
|
|
62
|
+
remaining: number;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
export declare const redisService: RedisService;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Redis } from '@upstash/redis';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { env } from '../config/env.js';
|
|
4
|
+
import { PLANS } from '../config/plans.js';
|
|
5
|
+
// In-Memory store fallback for local development or tests when Upstash is not configured
|
|
6
|
+
class MemoryRedisClient {
|
|
7
|
+
data = new Map();
|
|
8
|
+
expirations = new Map();
|
|
9
|
+
cleanKey(key) {
|
|
10
|
+
const exp = this.expirations.get(key);
|
|
11
|
+
if (exp && Date.now() > exp) {
|
|
12
|
+
this.data.delete(key);
|
|
13
|
+
this.expirations.delete(key);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async get(key) {
|
|
17
|
+
this.cleanKey(key);
|
|
18
|
+
const val = this.data.get(key);
|
|
19
|
+
if (val === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
return val;
|
|
22
|
+
}
|
|
23
|
+
async set(key, value, opts) {
|
|
24
|
+
this.data.set(key, value);
|
|
25
|
+
if (opts?.ex) {
|
|
26
|
+
this.expirations.set(key, Date.now() + opts.ex * 1000);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
this.expirations.delete(key);
|
|
30
|
+
}
|
|
31
|
+
return 'OK';
|
|
32
|
+
}
|
|
33
|
+
async incr(key) {
|
|
34
|
+
this.cleanKey(key);
|
|
35
|
+
const val = Number(this.data.get(key) || 0) + 1;
|
|
36
|
+
this.data.set(key, val);
|
|
37
|
+
return val;
|
|
38
|
+
}
|
|
39
|
+
async del(...keys) {
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (const k of keys) {
|
|
42
|
+
if (this.data.delete(k)) {
|
|
43
|
+
this.expirations.delete(k);
|
|
44
|
+
count++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return count;
|
|
48
|
+
}
|
|
49
|
+
async smembers(key) {
|
|
50
|
+
this.cleanKey(key);
|
|
51
|
+
const set = this.data.get(key);
|
|
52
|
+
if (set instanceof Set) {
|
|
53
|
+
return Array.from(set);
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
async sadd(key, member) {
|
|
58
|
+
this.cleanKey(key);
|
|
59
|
+
let set = this.data.get(key);
|
|
60
|
+
if (!(set instanceof Set)) {
|
|
61
|
+
set = new Set();
|
|
62
|
+
this.data.set(key, set);
|
|
63
|
+
}
|
|
64
|
+
const had = set.has(member);
|
|
65
|
+
set.add(member);
|
|
66
|
+
return had ? 0 : 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class RedisService {
|
|
70
|
+
static instance = null;
|
|
71
|
+
client;
|
|
72
|
+
isMemoryFallback;
|
|
73
|
+
constructor() {
|
|
74
|
+
const isUpstashConfigured = Boolean(env.UPSTASH_REDIS_REST_URL &&
|
|
75
|
+
env.UPSTASH_REDIS_REST_TOKEN &&
|
|
76
|
+
!env.UPSTASH_REDIS_REST_URL.includes('your-upstash-redis-url') &&
|
|
77
|
+
!env.UPSTASH_REDIS_REST_TOKEN.includes('your_upstash_rest_token'));
|
|
78
|
+
if (isUpstashConfigured) {
|
|
79
|
+
this.client = new Redis({
|
|
80
|
+
url: env.UPSTASH_REDIS_REST_URL,
|
|
81
|
+
token: env.UPSTASH_REDIS_REST_TOKEN,
|
|
82
|
+
});
|
|
83
|
+
this.isMemoryFallback = false;
|
|
84
|
+
this.seedDevKey();
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
this.client = new MemoryRedisClient();
|
|
88
|
+
this.isMemoryFallback = true;
|
|
89
|
+
this.seedDevKey();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
static getInstance() {
|
|
93
|
+
if (!RedisService.instance) {
|
|
94
|
+
RedisService.instance = new RedisService();
|
|
95
|
+
}
|
|
96
|
+
return RedisService.instance;
|
|
97
|
+
}
|
|
98
|
+
hashKey(rawKey) {
|
|
99
|
+
return crypto.createHash('sha256').update(rawKey.trim()).digest('hex');
|
|
100
|
+
}
|
|
101
|
+
seedDevKey() {
|
|
102
|
+
if (env.DEV_DEFAULT_API_KEY) {
|
|
103
|
+
const hash = this.hashKey(env.DEV_DEFAULT_API_KEY);
|
|
104
|
+
const devRecord = {
|
|
105
|
+
key_prefix: env.DEV_DEFAULT_API_KEY.slice(0, 12),
|
|
106
|
+
customer_id: 'cus_dev_sandbox',
|
|
107
|
+
plan: 'starter',
|
|
108
|
+
monthly_limit: PLANS.starter.monthlyLimit,
|
|
109
|
+
status: 'active',
|
|
110
|
+
created_at: new Date().toISOString(),
|
|
111
|
+
};
|
|
112
|
+
this.client.set(`api_keys:${hash}`, devRecord);
|
|
113
|
+
if ('sadd' in this.client) {
|
|
114
|
+
this.client.sadd('customer_keys:cus_dev_sandbox', hash);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async getApiKey(rawKey) {
|
|
119
|
+
const hash = this.hashKey(rawKey);
|
|
120
|
+
const key = `api_keys:${hash}`;
|
|
121
|
+
const record = await this.client.get(key);
|
|
122
|
+
if (!record) {
|
|
123
|
+
// In development mode, auto-provision any key to avoid friction
|
|
124
|
+
if (env.NODE_ENV === 'development') {
|
|
125
|
+
const autoRecord = {
|
|
126
|
+
key_prefix: rawKey.slice(0, 12),
|
|
127
|
+
customer_id: 'cus_dev_auto',
|
|
128
|
+
plan: 'starter',
|
|
129
|
+
monthly_limit: PLANS.starter.monthlyLimit,
|
|
130
|
+
status: 'active',
|
|
131
|
+
created_at: new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
await this.storeApiKey(rawKey, autoRecord);
|
|
134
|
+
return autoRecord;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (typeof record === 'string') {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(record);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return record;
|
|
147
|
+
}
|
|
148
|
+
async storeApiKey(rawKey, record) {
|
|
149
|
+
const hash = this.hashKey(rawKey);
|
|
150
|
+
const key = `api_keys:${hash}`;
|
|
151
|
+
await this.client.set(key, record);
|
|
152
|
+
// Track customer's keys for webhook lifecycle management
|
|
153
|
+
if ('sadd' in this.client) {
|
|
154
|
+
await this.client.sadd(`customer_keys:${record.customer_id}`, hash);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async updateKeyStatusByCustomer(customerId, status) {
|
|
158
|
+
let hashes = [];
|
|
159
|
+
if ('smembers' in this.client) {
|
|
160
|
+
hashes = await this.client.smembers(`customer_keys:${customerId}`);
|
|
161
|
+
}
|
|
162
|
+
let updatedCount = 0;
|
|
163
|
+
for (const hash of hashes) {
|
|
164
|
+
const key = `api_keys:${hash}`;
|
|
165
|
+
const record = await this.client.get(key);
|
|
166
|
+
if (record) {
|
|
167
|
+
const parsed = typeof record === 'string' ? JSON.parse(record) : record;
|
|
168
|
+
parsed.status = status;
|
|
169
|
+
await this.client.set(key, parsed);
|
|
170
|
+
updatedCount++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return updatedCount;
|
|
174
|
+
}
|
|
175
|
+
async updateCustomerPlan(customerId, plan, monthlyLimit) {
|
|
176
|
+
let hashes = [];
|
|
177
|
+
if ('smembers' in this.client) {
|
|
178
|
+
hashes = await this.client.smembers(`customer_keys:${customerId}`);
|
|
179
|
+
}
|
|
180
|
+
let updatedCount = 0;
|
|
181
|
+
for (const hash of hashes) {
|
|
182
|
+
const key = `api_keys:${hash}`;
|
|
183
|
+
const record = await this.client.get(key);
|
|
184
|
+
if (record) {
|
|
185
|
+
const parsed = typeof record === 'string' ? JSON.parse(record) : record;
|
|
186
|
+
parsed.plan = plan;
|
|
187
|
+
parsed.monthly_limit = monthlyLimit;
|
|
188
|
+
parsed.status = 'active';
|
|
189
|
+
await this.client.set(key, parsed);
|
|
190
|
+
updatedCount++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return updatedCount;
|
|
194
|
+
}
|
|
195
|
+
getMonthString(date = new Date()) {
|
|
196
|
+
const y = date.getUTCFullYear();
|
|
197
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
198
|
+
return `${y}-${m}`;
|
|
199
|
+
}
|
|
200
|
+
async incrementAndCheckQuota(rawKey, monthlyLimit) {
|
|
201
|
+
const hash = this.hashKey(rawKey);
|
|
202
|
+
const month = this.getMonthString();
|
|
203
|
+
const usageKey = `usage:${hash}:${month}`;
|
|
204
|
+
const usage = await this.client.incr(usageKey);
|
|
205
|
+
const allowed = usage <= monthlyLimit;
|
|
206
|
+
const remaining = Math.max(0, monthlyLimit - usage);
|
|
207
|
+
return {
|
|
208
|
+
allowed,
|
|
209
|
+
currentUsage: usage,
|
|
210
|
+
limit: monthlyLimit,
|
|
211
|
+
remaining,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async resetMonthlyQuotaForCustomer(customerId) {
|
|
215
|
+
let hashes = [];
|
|
216
|
+
if ('smembers' in this.client) {
|
|
217
|
+
hashes = await this.client.smembers(`customer_keys:${customerId}`);
|
|
218
|
+
}
|
|
219
|
+
const month = this.getMonthString();
|
|
220
|
+
for (const hash of hashes) {
|
|
221
|
+
const usageKey = `usage:${hash}:${month}`;
|
|
222
|
+
await this.client.del(usageKey);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
generateImageCacheHash(params) {
|
|
226
|
+
const target = params.url ? `url:${params.url}` : `html:${params.html || ''}`;
|
|
227
|
+
const payload = `${params.type}:${params.width}x${params.height}:${params.wait_for || ''}:${params.delay_ms || ''}:${target}`;
|
|
228
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
229
|
+
}
|
|
230
|
+
async getCachedImage(cacheHash) {
|
|
231
|
+
const key = `cache:img:${cacheHash}`;
|
|
232
|
+
const base64Data = await this.client.get(key);
|
|
233
|
+
if (!base64Data)
|
|
234
|
+
return null;
|
|
235
|
+
return Buffer.from(base64Data, 'base64');
|
|
236
|
+
}
|
|
237
|
+
async setCachedImage(cacheHash, imageBuffer, ttlSeconds = env.CACHE_TTL_SECONDS) {
|
|
238
|
+
const key = `cache:img:${cacheHash}`;
|
|
239
|
+
const base64Data = imageBuffer.toString('base64');
|
|
240
|
+
await this.client.set(key, base64Data, { ex: ttlSeconds });
|
|
241
|
+
}
|
|
242
|
+
generateScrapeCacheHash(params) {
|
|
243
|
+
const payload = `scrape:${params.strip_images}:${params.url.trim()}`;
|
|
244
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
245
|
+
}
|
|
246
|
+
async getCachedScrape(cacheHash) {
|
|
247
|
+
const key = `cache:scrape:${cacheHash}`;
|
|
248
|
+
const data = await this.client.get(key);
|
|
249
|
+
if (!data)
|
|
250
|
+
return null;
|
|
251
|
+
if (typeof data === 'string') {
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(data);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return data;
|
|
260
|
+
}
|
|
261
|
+
async setCachedScrape(cacheHash, result, ttlSeconds = 86400) {
|
|
262
|
+
const key = `cache:scrape:${cacheHash}`;
|
|
263
|
+
await this.client.set(key, JSON.stringify(result), { ex: ttlSeconds });
|
|
264
|
+
}
|
|
265
|
+
async getCurrentUsage(rawKey) {
|
|
266
|
+
const hash = this.hashKey(rawKey);
|
|
267
|
+
const month = this.getMonthString();
|
|
268
|
+
const usageKey = `usage:${hash}:${month}`;
|
|
269
|
+
const val = await this.client.get(usageKey);
|
|
270
|
+
return Number(val || 0);
|
|
271
|
+
}
|
|
272
|
+
async saveSessionKey(sessionId, apiKey, plan, monthlyLimit, ttlSeconds = 86400) {
|
|
273
|
+
const key = `session_keys:${sessionId}`;
|
|
274
|
+
await this.client.set(key, JSON.stringify({ apiKey, plan, monthlyLimit }), { ex: ttlSeconds });
|
|
275
|
+
}
|
|
276
|
+
async getSessionKey(sessionId) {
|
|
277
|
+
const key = `session_keys:${sessionId}`;
|
|
278
|
+
const val = await this.client.get(key);
|
|
279
|
+
if (!val)
|
|
280
|
+
return null;
|
|
281
|
+
if (typeof val === 'string') {
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(val);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return val;
|
|
290
|
+
}
|
|
291
|
+
async checkPlaygroundRateLimit(ip, maxPerHour = 25) {
|
|
292
|
+
const hour = Math.floor(Date.now() / (1000 * 60 * 60));
|
|
293
|
+
const key = `ratelimit:playground:${ip}:${hour}`;
|
|
294
|
+
const usage = await this.client.incr(key);
|
|
295
|
+
const allowed = usage <= maxPerHour;
|
|
296
|
+
return { allowed, remaining: Math.max(0, maxPerHour - usage) };
|
|
297
|
+
}
|
|
298
|
+
async getFreeKeyByEmail(email) {
|
|
299
|
+
const key = `free_key_email:${email.toLowerCase().trim()}`;
|
|
300
|
+
const val = await this.client.get(key);
|
|
301
|
+
return val || null;
|
|
302
|
+
}
|
|
303
|
+
async setFreeKeyEmail(email, rawApiKey) {
|
|
304
|
+
const key = `free_key_email:${email.toLowerCase().trim()}`;
|
|
305
|
+
await this.client.set(key, rawApiKey);
|
|
306
|
+
}
|
|
307
|
+
async checkClaimRateLimit(ip, maxPerHour = 10) {
|
|
308
|
+
const hour = Math.floor(Date.now() / (1000 * 60 * 60));
|
|
309
|
+
const key = `ratelimit:claim:${ip}:${hour}`;
|
|
310
|
+
const count = await this.client.incr(key);
|
|
311
|
+
return {
|
|
312
|
+
allowed: count <= maxPerHour,
|
|
313
|
+
remaining: Math.max(0, maxPerHour - count),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export const redisService = RedisService.getInstance();
|
|
318
|
+
//# sourceMappingURL=redis.js.map
|