feat: initial codebase v0.4.1
Some checks failed
Deploy to Staging / build-and-deploy (push) Failing after 9m44s
Some checks failed
Deploy to Staging / build-and-deploy (push) Failing after 9m44s
- Extract complete codebase from running staging pod - Add Dockerfile with multi-stage build for Node.js + Puppeteer - Configure CI/CD workflows for staging and production deployment - Include all source files, configs, and public assets
This commit is contained in:
commit
b58f634318
28 changed files with 5669 additions and 0 deletions
40
.forgejo/workflows/deploy.yml
Normal file
40
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: Deploy to Staging
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.cloonar.com
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
git.cloonar.com/openclawd/snapapi:staging
|
||||
git.cloonar.com/openclawd/snapapi:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Deploy to staging
|
||||
run: |
|
||||
echo "Triggering staging deployment..."
|
||||
# The actual deployment is handled by ArgoCD or similar GitOps tool
|
||||
# This would typically update a helm chart or kubernetes manifest repo
|
||||
45
.forgejo/workflows/promote.yml
Normal file
45
.forgejo/workflows/promote.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Promote to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.cloonar.com
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get latest staging image
|
||||
id: get_image
|
||||
run: |
|
||||
# Get the latest staging image digest
|
||||
STAGING_DIGEST=$(docker manifest inspect git.cloonar.com/openclawd/snapapi:staging --verbose | jq -r '.Descriptor.digest')
|
||||
echo "staging_digest=$STAGING_DIGEST" >> $GITHUB_OUTPUT
|
||||
|
||||
# Extract version from tag
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Re-tag for production
|
||||
run: |
|
||||
# Pull staging image and re-tag for production
|
||||
docker buildx imagetools create \
|
||||
--tag git.cloonar.com/openclawd/snapapi:latest \
|
||||
--tag git.cloonar.com/openclawd/snapapi:${{ steps.get_image.outputs.version }} \
|
||||
--tag git.cloonar.com/openclawd/snapapi:prod \
|
||||
git.cloonar.com/openclawd/snapapi:staging
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Triggering production deployment for version ${{ steps.get_image.outputs.version }}..."
|
||||
# The actual deployment is handled by ArgoCD or similar GitOps tool
|
||||
# This would typically update a helm chart or kubernetes manifest repo
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
*.pnp
|
||||
.pnp.js
|
||||
|
||||
# Production build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Runtime
|
||||
*.log
|
||||
logs
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN npx tsc
|
||||
|
||||
FROM node:20-slim
|
||||
RUN apt-get update && apt-get install -y chromium fonts-freefont-ttf --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY package*.json ./
|
||||
COPY public/ ./public/
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/index.js"]
|
||||
3194
package-lock.json
generated
Normal file
3194
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "snapapi",
|
||||
"version": "0.1.0",
|
||||
"description": "URL to Screenshot API — PNG, JPEG, WebP via simple REST API",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"compression": "^1.8.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
"nanoid": "^5.0.0",
|
||||
"pg": "^8.13.0",
|
||||
"pino": "^10.3.1",
|
||||
"puppeteer": "^24.0.0",
|
||||
"stripe": "^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
53
public/docs.html
Normal file
53
public/docs.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SnapAPI — API Documentation</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
body{margin:0;background:#1a1a2e}
|
||||
.topbar-wrapper img{content:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>")}
|
||||
.swagger-ui .topbar{background:#0a0e17;padding:8px 0}
|
||||
.swagger-ui .topbar .download-url-wrapper input{border:1px solid #3b82f6}
|
||||
.swagger-ui .info .title{font-family:'Inter',system-ui,sans-serif}
|
||||
/* Dark theme overrides */
|
||||
.swagger-ui{color:#e4e7ed}
|
||||
.swagger-ui .scheme-container{background:#141a28;box-shadow:none;border-bottom:1px solid #1e2a3f}
|
||||
.swagger-ui .opblock-tag{color:#e4e7ed;border-bottom:1px solid #1e2a3f}
|
||||
.swagger-ui .opblock .opblock-summary{border-color:#1e2a3f}
|
||||
.swagger-ui .opblock.opblock-post{background:rgba(79,143,255,0.05);border-color:rgba(79,143,255,0.3)}
|
||||
.swagger-ui .opblock.opblock-post .opblock-summary{border-color:rgba(79,143,255,0.3)}
|
||||
.swagger-ui .opblock.opblock-get{background:rgba(16,185,129,0.05);border-color:rgba(16,185,129,0.3)}
|
||||
.swagger-ui .opblock.opblock-get .opblock-summary{border-color:rgba(16,185,129,0.3)}
|
||||
.swagger-ui .btn{border-radius:6px}
|
||||
.swagger-ui .btn.execute{background:#3b82f6;border-color:#3b82f6}
|
||||
.swagger-ui .btn.execute:hover{background:#2563eb}
|
||||
.swagger-ui .response-col_status{color:#e4e7ed}
|
||||
.swagger-ui section.models{border:1px solid #1e2a3f;border-radius:8px}
|
||||
.swagger-ui section.models .model-container{background:#141a28}
|
||||
.back-link{display:inline-flex;align-items:center;gap:6px;color:#6da3ff;text-decoration:none;font-family:'Inter',sans-serif;font-size:.9rem;padding:12px 24px}
|
||||
.back-link:hover{color:#3b82f6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="back-link">← Back to SnapAPI</a>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
url:'/openapi.json',
|
||||
dom_id:'#swagger-ui',
|
||||
deepLinking:true,
|
||||
presets:[SwaggerUIBundle.presets.apis,SwaggerUIBundle.SwaggerUIStandalonePreset],
|
||||
layout:'BaseLayout',
|
||||
defaultModelsExpandDepth:1,
|
||||
defaultModelExpandDepth:1,
|
||||
docExpansion:'list',
|
||||
filter:true,
|
||||
tryItOutEnabled:true
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
666
public/index.html
Normal file
666
public/index.html
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SnapAPI — Screenshot API for Developers | EU-Hosted, GDPR Compliant</title>
|
||||
<meta name="description" content="Convert any URL to a pixel-perfect screenshot with a simple API call. PNG, JPEG, WebP. EU-hosted in Germany, fully GDPR compliant. Try it free in the playground.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:1180px;margin:0 auto;padding:0 24px}
|
||||
.section{padding:100px 0}
|
||||
.section-label{font-size:.75rem;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--primary);margin-bottom:12px}
|
||||
.section-title{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:16px}
|
||||
.section-subtitle{font-size:1.1rem;color:var(--text-secondary);max-width:600px;line-height:1.7}
|
||||
.text-center{text-align:center}
|
||||
.text-center .section-subtitle{margin:0 auto}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-secondary{background:rgba(255,255,255,0.06);color:var(--text);border:1px solid var(--border)}
|
||||
.btn-secondary:hover{background:rgba(255,255,255,0.1);border-color:var(--border-light);color:#fff}
|
||||
.btn-lg{padding:16px 36px;font-size:1.05rem;border-radius:12px}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
.hero{padding:100px 0 80px;text-align:center;position:relative;overflow:hidden}
|
||||
.hero::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:800px;height:800px;background:radial-gradient(circle,rgba(79,143,255,0.08) 0%,transparent 70%);pointer-events:none}
|
||||
.hero-badge{display:inline-flex;align-items:center;gap:8px;padding:6px 16px 6px 8px;background:rgba(79,143,255,0.1);border:1px solid rgba(79,143,255,0.2);border-radius:100px;font-size:.82rem;font-weight:500;color:var(--primary-light);margin-bottom:28px}
|
||||
.hero-badge .dot{width:8px;height:8px;background:var(--accent);border-radius:50%;animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.hero h1{font-size:4rem;font-weight:900;line-height:1.1;margin-bottom:24px;letter-spacing:-.02em}
|
||||
.hero h1 .gradient{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.hero p{font-size:1.2rem;color:var(--text-secondary);max-width:580px;margin:0 auto 40px;line-height:1.7}
|
||||
.hero-buttons{display:flex;gap:16px;justify-content:center;flex-wrap:wrap;margin-bottom:48px}
|
||||
.trust-badges{display:flex;gap:32px;justify-content:center;flex-wrap:wrap;margin-bottom:60px}
|
||||
.trust-badge{display:flex;align-items:center;gap:8px;font-size:.85rem;color:var(--muted);font-weight:500}
|
||||
.trust-badge .icon{font-size:1.1rem}
|
||||
.code-preview{max-width:700px;margin:0 auto;position:relative}
|
||||
.code-window{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.3)}
|
||||
.code-titlebar{display:flex;align-items:center;gap:8px;padding:14px 20px;background:rgba(0,0,0,0.2);border-bottom:1px solid var(--border)}
|
||||
.code-dot{width:12px;height:12px;border-radius:50%;background:#333}
|
||||
.code-dot:nth-child(1){background:#ff5f57}
|
||||
.code-dot:nth-child(2){background:#ffbd2e}
|
||||
.code-dot:nth-child(3){background:#28c840}
|
||||
.code-titlebar span{flex:1;text-align:center;font-size:.8rem;color:var(--muted);font-weight:500}
|
||||
.code-body{padding:24px;font-family:'JetBrains Mono',monospace;font-size:.85rem;line-height:1.9;overflow-x:auto;color:var(--text-secondary)}
|
||||
.code-body .kw{color:var(--purple)}
|
||||
.code-body .str{color:var(--accent)}
|
||||
.code-body .flag{color:var(--orange)}
|
||||
.code-body .cmt{color:#475569;font-style:italic}
|
||||
.code-body .url{color:var(--primary-light)}
|
||||
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:0;max-width:700px;margin:0 auto;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
||||
.stat{padding:32px;text-align:center;border-right:1px solid var(--border)}
|
||||
.stat:last-child{border-right:none}
|
||||
.stat .number{font-size:2rem;font-weight:800;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.stat .label{font-size:.82rem;color:var(--muted);margin-top:4px;font-weight:500}
|
||||
.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin-top:48px}
|
||||
.feature-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px 28px;transition:all .3s}
|
||||
.feature-card:hover{border-color:var(--border-light);background:var(--card-hover);transform:translateY(-2px)}
|
||||
.feature-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.5rem;margin-bottom:20px}
|
||||
.feature-icon.blue{background:rgba(79,143,255,0.12)}
|
||||
.feature-icon.green{background:rgba(16,185,129,0.12)}
|
||||
.feature-icon.purple{background:rgba(167,139,250,0.12)}
|
||||
.feature-icon.orange{background:rgba(245,158,11,0.12)}
|
||||
.feature-icon.pink{background:rgba(236,72,153,0.12)}
|
||||
.feature-icon.cyan{background:rgba(34,211,238,0.12)}
|
||||
.feature-card h3{font-size:1.05rem;font-weight:700;margin-bottom:10px}
|
||||
.feature-card p{color:var(--text-secondary);font-size:.9rem;line-height:1.6}
|
||||
.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:40px;margin-top:48px}
|
||||
.step{text-align:center;position:relative}
|
||||
.step-number{width:56px;height:56px;border-radius:16px;background:var(--primary-glow);border:1px solid rgba(79,143,255,0.3);display:flex;align-items:center;justify-content:center;font-size:1.3rem;font-weight:800;color:var(--primary);margin:0 auto 20px}
|
||||
.step h3{font-size:1.05rem;font-weight:700;margin-bottom:8px}
|
||||
.step p{color:var(--text-secondary);font-size:.88rem;line-height:1.6}
|
||||
.eu-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.eu-grid{display:grid;grid-template-columns:1fr 1fr;gap:60px;align-items:center;margin-top:48px}
|
||||
.eu-features{display:flex;flex-direction:column;gap:20px}
|
||||
.eu-feature{display:flex;gap:16px;align-items:flex-start}
|
||||
.eu-feature .check{width:36px;height:36px;border-radius:10px;background:var(--accent-glow);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1.1rem}
|
||||
.eu-feature h4{font-size:.95rem;font-weight:600;margin-bottom:4px}
|
||||
.eu-feature p{color:var(--text-secondary);font-size:.85rem;line-height:1.5}
|
||||
.eu-visual{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-xl);padding:48px;text-align:center}
|
||||
.eu-visual .flag{font-size:5rem;margin-bottom:16px}
|
||||
.eu-visual h3{font-size:1.3rem;font-weight:700;margin-bottom:8px}
|
||||
.eu-visual p{color:var(--text-secondary);font-size:.9rem}
|
||||
.playground{margin-top:48px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-xl);overflow:hidden}
|
||||
.playground-header{padding:24px 32px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.playground-header h3{font-size:1rem;font-weight:700}
|
||||
.playground-body{display:grid;grid-template-columns:1fr 1fr;min-height:400px}
|
||||
.playground-input{padding:32px;border-right:1px solid var(--border);display:flex;flex-direction:column;gap:20px}
|
||||
.playground-input label{font-size:.82rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:1px}
|
||||
.playground-input input,.playground-input select{width:100%;padding:12px 16px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.9rem;font-family:inherit;transition:border-color .2s}
|
||||
.playground-input input:focus,.playground-input select:focus{outline:none;border-color:var(--primary)}
|
||||
.playground-output{padding:32px;background:var(--bg2);display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:320px}
|
||||
.playground-output img{max-width:100%;max-height:300px;border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,0.3)}
|
||||
.playground-output .placeholder{color:var(--muted);font-size:.9rem;text-align:center}
|
||||
.playground-output .placeholder .icon{font-size:3rem;margin-bottom:12px;opacity:.3}
|
||||
#playground-loading{display:none;flex-direction:column;align-items:center;gap:12px;color:var(--muted)}
|
||||
.spinner{width:32px;height:32px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.pricing-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin-top:48px}
|
||||
.price-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px 28px;text-align:center;transition:all .3s;position:relative}
|
||||
.price-card:hover{border-color:var(--border-light);transform:translateY(-2px)}
|
||||
.price-card.featured{border-color:var(--primary);background:linear-gradient(180deg,rgba(79,143,255,0.05) 0%,var(--card) 100%);box-shadow:0 0 40px rgba(79,143,255,0.1)}
|
||||
.price-card.featured::before{content:'Most Popular';position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:4px 16px;border-radius:100px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:1px}
|
||||
.price-tier{font-size:.8rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:12px}
|
||||
.price-amount{font-size:2.8rem;font-weight:900;margin-bottom:4px}
|
||||
.price-amount .currency{font-size:1.2rem;vertical-align:super;font-weight:600;color:var(--text-secondary)}
|
||||
.price-amount .period{font-size:.9rem;font-weight:400;color:var(--muted)}
|
||||
.price-limit{color:var(--text-secondary);font-size:.88rem;margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)}
|
||||
.price-features{list-style:none;text-align:left;margin-bottom:28px}
|
||||
.price-features li{padding:7px 0;font-size:.85rem;color:var(--text-secondary);display:flex;align-items:center;gap:10px}
|
||||
.price-features li::before{content:'✓';color:var(--accent);font-weight:700;font-size:.9rem}
|
||||
.price-card .btn{width:100%;justify-content:center}
|
||||
.docs-preview{margin-top:48px}
|
||||
.endpoint-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:20px}
|
||||
.endpoint-header{display:flex;align-items:center;gap:12px;padding:20px 28px;cursor:pointer;transition:background .2s}
|
||||
.endpoint-header:hover{background:rgba(255,255,255,0.02)}
|
||||
.endpoint-method{padding:4px 12px;border-radius:6px;font-size:.75rem;font-weight:700;font-family:'JetBrains Mono',monospace;text-transform:uppercase}
|
||||
.method-post{background:rgba(79,143,255,0.15);color:var(--primary)}
|
||||
.method-get{background:rgba(16,185,129,0.15);color:var(--accent)}
|
||||
.endpoint-path{font-family:'JetBrains Mono',monospace;font-size:.9rem;font-weight:500}
|
||||
.endpoint-desc{color:var(--muted);font-size:.85rem;margin-left:auto}
|
||||
.faq-grid{max-width:800px;margin:48px auto 0;display:flex;flex-direction:column;gap:16px}
|
||||
.faq-item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
|
||||
.faq-q{padding:20px 24px;font-weight:600;font-size:.95rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:background .2s}
|
||||
.faq-q:hover{background:rgba(255,255,255,0.02)}
|
||||
.faq-q .arrow{transition:transform .3s;color:var(--muted)}
|
||||
.faq-a{padding:0 24px;max-height:0;overflow:hidden;transition:all .3s;color:var(--text-secondary);font-size:.9rem;line-height:1.7}
|
||||
.faq-item.open .faq-a{padding:0 24px 20px;max-height:200px}
|
||||
.faq-item.open .arrow{transform:rotate(180deg)}
|
||||
.cta-section{text-align:center;padding:80px 0 100px}
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:64px 48px}
|
||||
.cta-box h2{font-size:2.2rem;font-weight:800;margin-bottom:16px}
|
||||
.cta-box p{color:var(--text-secondary);font-size:1.1rem;margin-bottom:32px;max-width:500px;margin-left:auto;margin-right:auto}
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:900px){
|
||||
.hero h1{font-size:2.6rem}
|
||||
.features-grid{grid-template-columns:repeat(2,1fr)}
|
||||
.pricing-grid{grid-template-columns:1fr}
|
||||
.eu-grid{grid-template-columns:1fr}
|
||||
.steps{grid-template-columns:1fr;gap:24px}
|
||||
.footer-grid{grid-template-columns:1fr 1fr}
|
||||
.stats{grid-template-columns:repeat(3,1fr)}
|
||||
.playground-body{grid-template-columns:1fr}
|
||||
.playground-input{border-right:none;border-bottom:1px solid var(--border)}
|
||||
}
|
||||
@media(max-width:640px){
|
||||
.hero{padding:60px 0 40px}
|
||||
.hero h1{font-size:2rem}
|
||||
.hero p{font-size:1rem}
|
||||
.section{padding:60px 0}
|
||||
.section-title{font-size:1.8rem}
|
||||
.features-grid{grid-template-columns:1fr}
|
||||
.pricing-grid{grid-template-columns:1fr}
|
||||
.stats{grid-template-columns:1fr}
|
||||
.stat{border-right:none;border-bottom:1px solid var(--border)}
|
||||
.stat:last-child{border-bottom:none}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
.trust-badges{flex-direction:column;align-items:center;gap:12px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="#" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#playground">Try It Free</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="#docs">API Docs</a>
|
||||
<a href="/docs" target="_blank">Swagger</a>
|
||||
<a href="#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="hero-badge"><span class="dot"></span> Now live — EU-hosted screenshot API</div>
|
||||
<h1>URL → Screenshot<br><span class="gradient">in one API call</span></h1>
|
||||
<p>Render pixel-perfect screenshots of any webpage. PNG, JPEG, or WebP. Try it instantly in our playground — no signup needed. Hosted in Germany, fully GDPR compliant.</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="#playground" class="btn btn-primary btn-lg">Try It Free →</a>
|
||||
<a href="#pricing" class="btn btn-secondary btn-lg">View Pricing</a>
|
||||
</div>
|
||||
<div class="trust-badges">
|
||||
<div class="trust-badge"><span class="icon">🇪🇺</span> EU-Hosted (Germany)</div>
|
||||
<div class="trust-badge"><span class="icon">🔒</span> GDPR Compliant</div>
|
||||
<div class="trust-badge"><span class="icon">⚡</span> Sub-second Response</div>
|
||||
<div class="trust-badge"><span class="icon">🎯</span> No Signup to Try</div>
|
||||
</div>
|
||||
<div class="code-preview">
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar">
|
||||
<div class="code-dot"></div><div class="code-dot"></div><div class="code-dot"></div>
|
||||
<span>Terminal</span>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="code-body">
|
||||
<span class="cmt"># Take a screenshot of any URL</span>
|
||||
<span class="kw">curl</span> <span class="flag">-X POST</span> <span class="url">https://snapapi.eu/v1/screenshot</span> \
|
||||
<span class="flag">-H</span> <span class="str">"Authorization: Bearer YOUR_API_KEY"</span> \
|
||||
<span class="flag">-H</span> <span class="str">"Content-Type: application/json"</span> \
|
||||
<span class="flag">-d</span> <span class="str">'{"url":"https://example.com","format":"png"}'</span> \
|
||||
<span class="flag">-o</span> <span class="str">screenshot.png</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container">
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="number">100%</div><div class="label">EU-Hosted Infrastructure</div></div>
|
||||
<div class="stat"><div class="number">99.9%</div><div class="label">Uptime Target</div></div>
|
||||
<div class="stat"><div class="number"><3s</div><div class="label">Average Response Time</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playground FIRST - hero demo -->
|
||||
<section class="section" id="playground">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">Try it now</div>
|
||||
<h2 class="section-title">See it in action — no signup needed</h2>
|
||||
<p class="section-subtitle">Enter any URL and get a watermarked preview instantly. Like what you see? Get an API key for clean, unwatermarked screenshots.</p>
|
||||
<div class="playground">
|
||||
<div class="playground-header">
|
||||
<h3>📸 API Playground</h3>
|
||||
<span style="font-size:.8rem;color:var(--muted)">Free demo · 5 requests/hour · watermarked</span>
|
||||
</div>
|
||||
<div class="playground-body">
|
||||
<div class="playground-input">
|
||||
<div>
|
||||
<label>URL to capture</label>
|
||||
<input type="url" id="pg-url" value="https://example.com" placeholder="https://example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label>Format</label>
|
||||
<select id="pg-format">
|
||||
<option value="png">PNG</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<label>Width</label>
|
||||
<input type="number" id="pg-width" value="1280" min="320" max="1920">
|
||||
</div>
|
||||
<div>
|
||||
<label>Height</label>
|
||||
<input type="number" id="pg-height" value="800" min="200" max="1080">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="runPlayground()" id="pg-btn" style="margin-top:auto">
|
||||
Take Screenshot →
|
||||
</button>
|
||||
<p style="font-size:.75rem;color:var(--muted);text-align:center">Playground screenshots are watermarked. <a href="#pricing">Get an API key</a> for clean output.</p>
|
||||
</div>
|
||||
<div class="playground-output">
|
||||
<div class="placeholder" id="pg-placeholder">
|
||||
<div class="icon">📷</div>
|
||||
<p>Your screenshot will appear here</p>
|
||||
</div>
|
||||
<div id="playground-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Capturing screenshot...</span>
|
||||
</div>
|
||||
<img id="pg-result" style="display:none" alt="Screenshot result">
|
||||
<p id="pg-error" style="display:none;color:#f87171;font-size:.9rem;text-align:center"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="features">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">Features</div>
|
||||
<h2 class="section-title">Everything you need to capture the web</h2>
|
||||
<p class="section-subtitle">A powerful API with sensible defaults and full customization. No browser management, no infrastructure headaches.</p>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon blue">🖼️</div>
|
||||
<h3>Multiple Formats</h3>
|
||||
<p>PNG, JPEG, or WebP output. Control quality, resolution, and device pixel ratio up to 3x Retina.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon green">⚡</div>
|
||||
<h3>Fast & Reliable</h3>
|
||||
<p>Powered by headless Chromium with a managed browser pool. Auto-recycling ensures consistent performance.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon purple">🔒</div>
|
||||
<h3>Secure by Default</h3>
|
||||
<p>SSRF protection blocks internal IPs and metadata endpoints. Rate limiting and strict input validation built in.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon orange">📐</div>
|
||||
<h3>Custom Viewports</h3>
|
||||
<p>Set width, height, and device scale factor. Emulate any device — mobile, tablet, desktop, or 4K.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon pink">📸</div>
|
||||
<h3>Full-Page Capture</h3>
|
||||
<p>Capture entire scrollable pages or just the visible viewport. Perfect for long-form content and landing pages.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon cyan">🎯</div>
|
||||
<h3>Wait for Elements</h3>
|
||||
<p>Use CSS selectors to wait for specific elements before capturing. Ideal for SPAs and dynamic content.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" style="padding-top:0">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">How it works</div>
|
||||
<h2 class="section-title">Three steps to pixel-perfect screenshots</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<h3>Try the Playground</h3>
|
||||
<p>Test our API instantly — no signup needed. See watermarked results in seconds to evaluate quality.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<h3>Get an API Key</h3>
|
||||
<p>Choose a plan that fits your needs. Get your API key and start making authenticated requests.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<h3>Clean Screenshots</h3>
|
||||
<p>API keys deliver unwatermarked, full-resolution screenshots. PNG, JPEG, or WebP — your choice.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section eu-section" id="eu">
|
||||
<div class="container">
|
||||
<div class="section-label">Privacy & Compliance</div>
|
||||
<h2 class="section-title">Built for European businesses</h2>
|
||||
<p class="section-subtitle">Your data never leaves the EU. Full GDPR compliance by design, not as an afterthought.</p>
|
||||
<div class="eu-grid">
|
||||
<div class="eu-features">
|
||||
<div class="eu-feature">
|
||||
<div class="check">✓</div>
|
||||
<div>
|
||||
<h4>Hosted in Germany</h4>
|
||||
<p>All servers, storage, and processing happens in German data centers. Zero data transfer outside the EU.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eu-feature">
|
||||
<div class="check">✓</div>
|
||||
<div>
|
||||
<h4>GDPR Compliant</h4>
|
||||
<p>Full compliance with EU data protection regulations. No US Cloud Act exposure. Austrian company (Cloonar Technologies GmbH).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eu-feature">
|
||||
<div class="check">✓</div>
|
||||
<div>
|
||||
<h4>No Third-Party Tracking</h4>
|
||||
<p>No analytics cookies, no external trackers, no data sharing with third parties. Your API usage stays private.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eu-feature">
|
||||
<div class="check">✓</div>
|
||||
<div>
|
||||
<h4>Data Processing Agreement</h4>
|
||||
<p>DPA available on request for business customers. Standard contractual clauses included.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eu-visual">
|
||||
<div class="flag">🇪🇺</div>
|
||||
<h3>100% European</h3>
|
||||
<p>From code to cloud — everything stays in the EU. Austrian company, German infrastructure, European values.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="pricing">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">Pricing</div>
|
||||
<h2 class="section-title">Simple, transparent pricing</h2>
|
||||
<p class="section-subtitle">Try free in the playground. Pay only when you need clean, unwatermarked screenshots via API.</p>
|
||||
<div class="pricing-grid">
|
||||
<div class="price-card">
|
||||
<div class="price-tier">Starter</div>
|
||||
<div class="price-amount"><span class="currency">€</span>9<span class="period">/mo</span></div>
|
||||
<div class="price-limit">1,000 screenshots/month</div>
|
||||
<ul class="price-features">
|
||||
<li>All output formats (PNG, JPEG, WebP)</li>
|
||||
<li>Custom viewports & device scale</li>
|
||||
<li>Full-page capture</li>
|
||||
<li>No watermark</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" onclick="checkout('starter')">Get Started</button>
|
||||
</div>
|
||||
<div class="price-card featured">
|
||||
<div class="price-tier">Pro</div>
|
||||
<div class="price-amount"><span class="currency">€</span>29<span class="period">/mo</span></div>
|
||||
<div class="price-limit">5,000 screenshots/month</div>
|
||||
<ul class="price-features">
|
||||
<li>Everything in Starter</li>
|
||||
<li>Priority rendering</li>
|
||||
<li>Webhook callbacks</li>
|
||||
<li>Batch API</li>
|
||||
<li>Priority support</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" onclick="checkout('pro')">Get Started</button>
|
||||
</div>
|
||||
<div class="price-card">
|
||||
<div class="price-tier">Business</div>
|
||||
<div class="price-amount"><span class="currency">€</span>79<span class="period">/mo</span></div>
|
||||
<div class="price-limit">25,000 screenshots/month</div>
|
||||
<ul class="price-features">
|
||||
<li>Everything in Pro</li>
|
||||
<li>SLA guarantee</li>
|
||||
<li>Dedicated support</li>
|
||||
<li>Custom integrations</li>
|
||||
<li>DPA included</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" onclick="checkout('business')">Get Started</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:32px;color:var(--text-secondary);font-size:.95rem">
|
||||
Want to test first? <a href="#playground" style="font-weight:600">Try the playground</a> — free, instant, no signup.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="docs" style="padding-top:0">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">API Documentation</div>
|
||||
<h2 class="section-title">Developer-friendly API</h2>
|
||||
<p class="section-subtitle">RESTful API with JSON request/response. Interactive Swagger docs available at <a href="/docs">/docs</a>.</p>
|
||||
<div class="docs-preview" style="text-align:left">
|
||||
<div class="endpoint-card">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="endpoint-method method-post">POST</span>
|
||||
<span class="endpoint-path">/v1/screenshot</span>
|
||||
<span class="endpoint-desc">Take a screenshot (requires API key, no watermark)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="endpoint-card">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="endpoint-method method-post">POST</span>
|
||||
<span class="endpoint-path">/v1/playground</span>
|
||||
<span class="endpoint-desc">Free demo (no auth, watermarked, 5 req/hr)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="endpoint-card">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="endpoint-method method-get">GET</span>
|
||||
<span class="endpoint-path">/health</span>
|
||||
<span class="endpoint-desc">Health check & browser pool status</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:24px;font-size:.9rem;color:var(--text-secondary)">
|
||||
Full interactive documentation with "Try it" available at <a href="/docs" class="btn btn-secondary btn-sm" style="margin-left:8px">Open Swagger Docs →</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" style="padding-top:0">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">FAQ</div>
|
||||
<h2 class="section-title">Frequently asked questions</h2>
|
||||
<div class="faq-grid">
|
||||
<div class="faq-item">
|
||||
<div class="faq-q" onclick="this.parentElement.classList.toggle('open')">
|
||||
Where are your servers located?
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="faq-a">All our infrastructure is hosted in Germany (EU). Your data never leaves the European Union. We are operated by Cloonar Technologies GmbH, an Austrian company.</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<div class="faq-q" onclick="this.parentElement.classList.toggle('open')">
|
||||
Can I try before buying?
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="faq-a">Yes! Our playground lets you test the API instantly — no signup, no credit card. Screenshots are watermarked in the playground, but you can evaluate quality, speed, and rendering before committing to a paid plan.</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<div class="faq-q" onclick="this.parentElement.classList.toggle('open')">
|
||||
What's the difference between playground and paid API?
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="faq-a">The playground is free with 5 requests/hour and adds a visible watermark. Paid API keys give you clean, unwatermarked screenshots with higher limits (1,000–25,000/month), full-page capture, custom viewports up to 4K, and priority support.</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<div class="faq-q" onclick="this.parentElement.classList.toggle('open')">
|
||||
What formats do you support?
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="faq-a">We support PNG, JPEG, and WebP output formats. You can control quality (for JPEG/WebP), resolution, viewport size, and device pixel ratio up to 3x for Retina displays.</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<div class="faq-q" onclick="this.parentElement.classList.toggle('open')">
|
||||
Is SnapAPI GDPR compliant?
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="faq-a">Yes. We are fully GDPR compliant. All data processing happens within the EU. No data is shared with third parties. A Data Processing Agreement (DPA) is available for business customers.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-section">
|
||||
<div class="container">
|
||||
<div class="cta-box">
|
||||
<h2>Ready to capture the web?</h2>
|
||||
<p>Try our playground for free — no signup needed. See the quality for yourself, then upgrade when you're ready.</p>
|
||||
<div style="display:flex;gap:16px;justify-content:center;flex-wrap:wrap">
|
||||
<a href="#playground" class="btn btn-primary btn-lg">Try Playground Free →</a>
|
||||
<a href="#pricing" class="btn btn-secondary btn-lg">View Pricing</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<h4>📸 SnapAPI</h4>
|
||||
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Product</h5>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="#playground">Playground</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Developers</h5>
|
||||
<a href="/docs">Swagger / OpenAPI</a>
|
||||
<a href="#docs">Quick Start</a>
|
||||
<a href="/health">Status</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="#impressum">Impressum</a>
|
||||
<a href="#privacy">Privacy Policy</a>
|
||||
<a href="#terms">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Playground - calls /v1/playground (no auth needed)
|
||||
async function runPlayground(){
|
||||
var url=document.getElementById('pg-url').value;
|
||||
var format=document.getElementById('pg-format').value;
|
||||
var width=parseInt(document.getElementById('pg-width').value)||1280;
|
||||
var height=parseInt(document.getElementById('pg-height').value)||800;
|
||||
if(!url){alert('Please enter a URL');return}
|
||||
|
||||
var btn=document.getElementById('pg-btn');
|
||||
var loading=document.getElementById('playground-loading');
|
||||
var placeholder=document.getElementById('pg-placeholder');
|
||||
var result=document.getElementById('pg-result');
|
||||
var error=document.getElementById('pg-error');
|
||||
|
||||
btn.disabled=true;btn.textContent='Capturing...';
|
||||
placeholder.style.display='none';result.style.display='none';error.style.display='none';
|
||||
loading.style.display='flex';
|
||||
|
||||
try{
|
||||
var r=await fetch('/v1/playground',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({url:url,format:format,width:width,height:height})
|
||||
});
|
||||
if(!r.ok){var d=await r.json().catch(function(){return{}});throw new Error(d.error||'HTTP '+r.status)}
|
||||
var blob=await r.blob();
|
||||
result.src=URL.createObjectURL(blob);
|
||||
result.style.display='block';
|
||||
}catch(ex){
|
||||
error.textContent='Error: '+ex.message;error.style.display='block';
|
||||
}finally{
|
||||
loading.style.display='none';
|
||||
btn.disabled=false;btn.textContent='Take Screenshot →';
|
||||
}
|
||||
}
|
||||
|
||||
// FAQ toggles
|
||||
document.querySelectorAll('.faq-q').forEach(function(q){
|
||||
q.addEventListener('click',function(){this.parentElement.classList.toggle('open')});
|
||||
});
|
||||
|
||||
// Smooth scroll
|
||||
document.querySelectorAll('a[href^="#"]').forEach(function(a){
|
||||
a.addEventListener('click',function(e){
|
||||
var id=a.getAttribute('href');
|
||||
if(id==='#')return;
|
||||
var el=document.querySelector(id);
|
||||
if(el){e.preventDefault();el.scrollIntoView({behavior:'smooth',block:'start'})}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
async function checkout(plan) {
|
||||
try {
|
||||
const res = await fetch("/v1/billing/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) window.location.href = data.url;
|
||||
else alert(data.error || "Failed to start checkout");
|
||||
} catch (e) { alert("Network error"); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
118
public/openapi.json
Normal file
118
public/openapi.json
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "SnapAPI — Screenshot API",
|
||||
"description": "Convert any URL to a pixel-perfect screenshot. EU-hosted, GDPR compliant.\n\n## Authentication\nAPI screenshot requests require an API key:\n- `Authorization: Bearer YOUR_API_KEY` header, or\n- `X-API-Key: YOUR_API_KEY` header\n\n## Playground\nThe `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n## Rate Limits\n- 120 requests per minute per IP (global)\n- 5 requests per hour per IP (playground)\n- Monthly screenshot limits based on your plan tier (API)",
|
||||
"version": "0.3.0",
|
||||
"contact": {
|
||||
"name": "SnapAPI Support",
|
||||
"url": "https://snapapi.eu",
|
||||
"email": "support@snapapi.eu"
|
||||
},
|
||||
"license": {"name": "Proprietary"}
|
||||
},
|
||||
"servers": [{"url": "https://snapapi.eu", "description": "Production (EU — Germany)"}],
|
||||
"tags": [
|
||||
{"name": "Screenshots", "description": "Screenshot capture endpoints"},
|
||||
{"name": "Playground", "description": "Free demo (no auth, watermarked)"},
|
||||
{"name": "System", "description": "Health and status endpoints"}
|
||||
],
|
||||
"paths": {
|
||||
"/v1/screenshot": {
|
||||
"post": {
|
||||
"tags": ["Screenshots"],
|
||||
"summary": "Take a screenshot (authenticated)",
|
||||
"description": "Capture a pixel-perfect, unwatermarked screenshot. Requires an API key.",
|
||||
"operationId": "takeScreenshot",
|
||||
"security": [{"BearerAuth": []}, {"ApiKeyAuth": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ScreenshotRequest"},
|
||||
"examples": {
|
||||
"simple": {"summary": "Simple screenshot", "value": {"url": "https://example.com"}},
|
||||
"hd_jpeg": {"summary": "HD JPEG", "value": {"url": "https://github.com", "format": "jpeg", "width": 1920, "height": 1080, "quality": 90}},
|
||||
"mobile": {"summary": "Mobile", "value": {"url": "https://example.com", "width": 375, "height": 812, "deviceScale": 2}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {"description": "Screenshot captured", "content": {"image/png": {"schema": {"type": "string", "format": "binary"}}, "image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/webp": {"schema": {"type": "string", "format": "binary"}}}},
|
||||
"400": {"description": "Invalid request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"401": {"description": "Missing API key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"403": {"description": "Invalid API key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"429": {"description": "Rate/usage limit exceeded", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"503": {"description": "Service busy", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"504": {"description": "Timeout", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/playground": {
|
||||
"post": {
|
||||
"tags": ["Playground"],
|
||||
"summary": "Free demo screenshot (watermarked)",
|
||||
"description": "Take a watermarked screenshot without authentication. Limited to 5 requests per hour per IP, max 1920x1080 resolution. Perfect for evaluating the API before purchasing a plan.",
|
||||
"operationId": "playgroundScreenshot",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": {"type": "string", "format": "uri", "description": "URL to capture", "example": "https://example.com"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "webp"], "default": "png"},
|
||||
"width": {"type": "integer", "minimum": 320, "maximum": 1920, "default": 1280},
|
||||
"height": {"type": "integer", "minimum": 200, "maximum": 1080, "default": 800}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {"description": "Watermarked screenshot", "content": {"image/png": {"schema": {"type": "string", "format": "binary"}}}},
|
||||
"400": {"description": "Invalid request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"429": {"description": "Rate limit (5/hr)", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"503": {"description": "Service busy", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": ["System"],
|
||||
"summary": "Health check",
|
||||
"operationId": "healthCheck",
|
||||
"responses": {
|
||||
"200": {"description": "Healthy", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": "string"}, "version": {"type": "string"}, "uptime": {"type": "number"}, "browser": {"type": "object"}}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {"type": "http", "scheme": "bearer"},
|
||||
"ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}
|
||||
},
|
||||
"schemas": {
|
||||
"ScreenshotRequest": {
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": {"type": "string", "format": "uri", "example": "https://example.com"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "webp"], "default": "png"},
|
||||
"width": {"type": "integer", "minimum": 320, "maximum": 3840, "default": 1280},
|
||||
"height": {"type": "integer", "minimum": 200, "maximum": 2160, "default": 800},
|
||||
"fullPage": {"type": "boolean", "default": false},
|
||||
"quality": {"type": "integer", "minimum": 1, "maximum": 100, "default": 80},
|
||||
"waitForSelector": {"type": "string"},
|
||||
"deviceScale": {"type": "number", "minimum": 1, "maximum": 3, "default": 1},
|
||||
"delay": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 0}
|
||||
}
|
||||
},
|
||||
"Error": {"type": "object", "required": ["error"], "properties": {"error": {"type": "string"}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
public/status.html
Normal file
68
public/status.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SnapAPI Status</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0a;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh}
|
||||
.container{max-width:480px;width:100%;padding:2rem}
|
||||
h1{font-size:1.5rem;margin-bottom:2rem;font-weight:600}
|
||||
.status-header{display:flex;align-items:center;gap:12px;margin-bottom:2rem}
|
||||
.dot{width:12px;height:12px;border-radius:50%;background:#ef4444;flex-shrink:0}
|
||||
.dot.ok{background:#22c55e;animation:pulse 2s infinite}
|
||||
.dot.degraded{background:#f59e0b}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.status-text{font-size:1.25rem;font-weight:600}
|
||||
.status-text.ok{color:#22c55e}
|
||||
.status-text.down{color:#ef4444}
|
||||
.status-text.degraded{color:#f59e0b}
|
||||
.metrics{display:flex;flex-direction:column;gap:1rem}
|
||||
.metric{background:#141414;border-radius:8px;padding:1rem;display:flex;justify-content:space-between;align-items:center}
|
||||
.metric-label{color:#888;font-size:.85rem}
|
||||
.metric-value{font-weight:500;font-size:.95rem}
|
||||
.footer{margin-top:2rem;color:#555;font-size:.75rem;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>SnapAPI Status</h1>
|
||||
<div class="status-header">
|
||||
<div class="dot" id="dot"></div>
|
||||
<div class="status-text" id="statusText">Checking...</div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><span class="metric-label">Response Time</span><span class="metric-value" id="responseTime">—</span></div>
|
||||
<div class="metric"><span class="metric-label">Browser Pool</span><span class="metric-value" id="pool">—</span></div>
|
||||
<div class="metric"><span class="metric-label">Uptime</span><span class="metric-value" id="uptime">—</span></div>
|
||||
<div class="metric"><span class="metric-label">Last Checked</span><span class="metric-value" id="lastChecked">—</span></div>
|
||||
</div>
|
||||
<div class="footer">Auto-refreshes every 30s</div>
|
||||
</div>
|
||||
<script>
|
||||
function fmt(s){if(s<60)return s+"s";if(s<3600)return Math.floor(s/60)+"m "+s%60+"s";const h=Math.floor(s/3600);return h+"h "+Math.floor((s%3600)/60)+"m"}
|
||||
async function check(){
|
||||
const dot=document.getElementById("dot"),st=document.getElementById("statusText");
|
||||
try{
|
||||
const t0=performance.now();
|
||||
const r=await fetch("/health");
|
||||
const ms=Math.round(performance.now()-t0);
|
||||
const d=await r.json();
|
||||
const ok=d.status==="ok";
|
||||
dot.className="dot"+(ok?" ok":" degraded");
|
||||
st.className="status-text"+(ok?" ok":" degraded");
|
||||
st.textContent=ok?"Operational":"Degraded";
|
||||
document.getElementById("responseTime").textContent=ms+"ms";
|
||||
if(d.browserPool)document.getElementById("pool").textContent=d.browserPool.active+"/"+d.browserPool.total+" active";
|
||||
if(d.uptime)document.getElementById("uptime").textContent=fmt(d.uptime);
|
||||
document.getElementById("lastChecked").textContent=new Date().toLocaleTimeString();
|
||||
}catch(e){
|
||||
dot.className="dot";st.className="status-text down";st.textContent="Down";
|
||||
document.getElementById("responseTime").textContent="—";
|
||||
document.getElementById("lastChecked").textContent=new Date().toLocaleTimeString();
|
||||
}}
|
||||
check();setInterval(check,30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
157
src/index.ts
Normal file
157
src/index.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import express from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { compressionMiddleware } from "./middleware/compression.js";
|
||||
import logger from "./services/logger.js";
|
||||
import helmet from "helmet";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { screenshotRouter } from "./routes/screenshot.js";
|
||||
import { healthRouter } from "./routes/health.js";
|
||||
import { playgroundRouter } from "./routes/playground.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { usageMiddleware, loadUsageData } from "./middleware/usage.js";
|
||||
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||
import { loadKeys, getAllKeys } from "./services/keys.js";
|
||||
import { initDatabase, pool } from "./services/db.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { statusRouter } from "./routes/status.js";
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Helmet with relaxed CSP for /docs (Swagger UI needs external scripts)
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === "/docs" || req.path === "/docs.html") {
|
||||
helmet({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
},
|
||||
},
|
||||
})(req, res, next);
|
||||
} else {
|
||||
helmet({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
connectSrc: ["'self'"],
|
||||
scriptSrcAttr: ["'unsafe-inline'"],
|
||||
},
|
||||
},
|
||||
})(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
// Request ID + logging
|
||||
app.use((req, res, next) => {
|
||||
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
|
||||
(req as any).requestId = requestId;
|
||||
res.setHeader("X-Request-Id", requestId);
|
||||
const start = Date.now();
|
||||
res.on("finish", () => {
|
||||
if (req.path !== "/health") {
|
||||
logger.info({ method: req.method, path: req.path, status: res.statusCode, ms: Date.now() - start, requestId }, "request");
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(compressionMiddleware);
|
||||
|
||||
// CORS
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
|
||||
res.setHeader("Access-Control-Max-Age", "86400");
|
||||
if (req.method === "OPTIONS") { res.status(204).end(); return; }
|
||||
next();
|
||||
});
|
||||
|
||||
// Raw body for Stripe webhook (must be before JSON parser)
|
||||
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
// Global rate limiting
|
||||
app.use(rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
|
||||
|
||||
// Public routes
|
||||
app.use("/health", healthRouter);
|
||||
app.use("/v1/billing", billingRouter);
|
||||
app.use("/status", statusRouter);
|
||||
app.use("/v1/playground", playgroundRouter);
|
||||
|
||||
// Authenticated routes
|
||||
app.use("/v1/screenshot", authMiddleware, usageMiddleware, screenshotRouter);
|
||||
|
||||
// API info
|
||||
app.get("/api", (_req, res) => {
|
||||
res.json({
|
||||
name: "SnapAPI",
|
||||
version: "0.3.0",
|
||||
endpoints: [
|
||||
"POST /v1/playground — Try the API (no auth, watermarked, 5 req/hr)",
|
||||
"POST /v1/screenshot — Take a screenshot (requires API key)",
|
||||
"GET /health — Health check",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Swagger docs
|
||||
app.get("/docs", (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../public/docs.html"));
|
||||
});
|
||||
// Static files (landing page)
|
||||
app.use(express.static(path.join(__dirname, "../public"), { etag: true }));
|
||||
|
||||
// 404
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith("/v1/") || req.path.startsWith("/api")) {
|
||||
res.status(404).json({ error: "Not Found: " + req.method + " " + req.path });
|
||||
} else {
|
||||
res.status(404).sendFile(path.join(__dirname, "../public/index.html"));
|
||||
}
|
||||
});
|
||||
|
||||
async function start() {
|
||||
await initDatabase();
|
||||
await loadKeys();
|
||||
await loadUsageData();
|
||||
await initBrowser();
|
||||
logger.info("Loaded " + getAllKeys().length + " API keys");
|
||||
|
||||
const server = app.listen(PORT, () => logger.info("SnapAPI running on :" + PORT));
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info(signal + " received, shutting down...");
|
||||
await new Promise<void>(resolve => {
|
||||
const t = setTimeout(resolve, 10_000);
|
||||
server.close(() => { clearTimeout(t); resolve(); });
|
||||
});
|
||||
try { await closeBrowser(); } catch {}
|
||||
try { await pool.end(); } catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
}
|
||||
|
||||
start().catch(err => { logger.error({ err }, "Failed to start"); process.exit(1); });
|
||||
|
||||
export { app };
|
||||
22
src/middleware/auth.ts
Normal file
22
src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { isValidKey, getKeyInfo } from "../services/keys.js";
|
||||
|
||||
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const header = req.headers.authorization;
|
||||
const xApiKey = req.headers["x-api-key"] as string | undefined;
|
||||
let key: string | undefined;
|
||||
|
||||
if (header?.startsWith("Bearer ")) key = header.slice(7);
|
||||
else if (xApiKey) key = xApiKey;
|
||||
|
||||
if (!key) {
|
||||
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key> or X-API-Key: <key>" });
|
||||
return;
|
||||
}
|
||||
if (!(await isValidKey(key))) {
|
||||
res.status(403).json({ error: "Invalid API key" });
|
||||
return;
|
||||
}
|
||||
(req as any).apiKeyInfo = await getKeyInfo(key);
|
||||
next();
|
||||
}
|
||||
11
src/middleware/compression.ts
Normal file
11
src/middleware/compression.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import compression from "compression";
|
||||
|
||||
export const compressionMiddleware = compression({
|
||||
level: 6,
|
||||
threshold: 1024,
|
||||
filter: (req, res) => {
|
||||
// Don't compress screenshot responses (already binary)
|
||||
if (res.getHeader("Content-Type")?.toString().startsWith("image/")) return false;
|
||||
return compression.filter(req, res);
|
||||
},
|
||||
});
|
||||
91
src/middleware/usage.ts
Normal file
91
src/middleware/usage.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import logger from "../services/logger.js";
|
||||
import { queryWithRetry, connectWithRetry } from "../services/db.js";
|
||||
import { getTierLimit } from "../services/keys.js";
|
||||
|
||||
let usage = new Map<string, { count: number; monthKey: string }>();
|
||||
const dirtyKeys = new Set<string>();
|
||||
const FLUSH_INTERVAL_MS = 5000;
|
||||
|
||||
function getMonthKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export async function loadUsageData(): Promise<void> {
|
||||
try {
|
||||
const result = await queryWithRetry("SELECT key, count, month_key FROM usage");
|
||||
usage = new Map();
|
||||
for (const row of result.rows) {
|
||||
usage.set(row.key, { count: row.count, monthKey: row.month_key });
|
||||
}
|
||||
logger.info(`Loaded usage for ${usage.size} keys`);
|
||||
} catch {
|
||||
usage = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function flushDirtyEntries(): Promise<void> {
|
||||
if (dirtyKeys.size === 0) return;
|
||||
const keys = [...dirtyKeys];
|
||||
const client = await connectWithRetry();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
for (const key of keys) {
|
||||
const record = usage.get(key);
|
||||
if (!record) continue;
|
||||
await client.query(
|
||||
`INSERT INTO usage (key, month_key, count) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key, month_key) DO UPDATE SET count = $3`,
|
||||
[key, record.monthKey, record.count]
|
||||
);
|
||||
dirtyKeys.delete(key);
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
logger.error({ err }, "Failed to flush usage");
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(flushDirtyEntries, FLUSH_INTERVAL_MS);
|
||||
process.on("SIGTERM", () => { flushDirtyEntries().catch(() => {}); });
|
||||
|
||||
export function usageMiddleware(req: any, res: any, next: any): void {
|
||||
const keyInfo = req.apiKeyInfo;
|
||||
if (!keyInfo) { next(); return; }
|
||||
|
||||
const key = keyInfo.key;
|
||||
const monthKey = getMonthKey();
|
||||
const limit = getTierLimit(keyInfo.tier);
|
||||
|
||||
const record = usage.get(key);
|
||||
if (record && record.monthKey === monthKey && record.count >= limit) {
|
||||
res.status(429).json({
|
||||
error: `Monthly limit reached (${limit} screenshots/month for ${keyInfo.tier} tier).`,
|
||||
usage: record.count,
|
||||
limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Track
|
||||
if (!record || record.monthKey !== monthKey) {
|
||||
usage.set(key, { count: 1, monthKey });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
dirtyKeys.add(key);
|
||||
|
||||
// Attach usage info to response
|
||||
const current = usage.get(key)!;
|
||||
res.setHeader("X-Usage-Count", current.count.toString());
|
||||
res.setHeader("X-Usage-Limit", limit.toString());
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function getUsageForKey(key: string): { count: number; monthKey: string } | undefined {
|
||||
return usage.get(key);
|
||||
}
|
||||
276
src/routes/billing.ts
Normal file
276
src/routes/billing.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import Stripe from "stripe";
|
||||
import logger from "../services/logger.js";
|
||||
import { createPaidKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2025-01-27.acacia" as any,
|
||||
});
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || "https://snapapi.eu";
|
||||
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
// DocFast product ID — NEVER process events for this
|
||||
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
|
||||
|
||||
// Plan definitions
|
||||
const PLANS: Record<string, { name: string; amount: number; description: string; tier: "starter" | "pro" | "business" }> = {
|
||||
starter: { name: "SnapAPI Starter", amount: 900, description: "1,000 screenshots/month", tier: "starter" },
|
||||
pro: { name: "SnapAPI Pro", amount: 2900, description: "5,000 screenshots/month", tier: "pro" },
|
||||
business: { name: "SnapAPI Business", amount: 7900, description: "25,000 screenshots/month", tier: "business" },
|
||||
};
|
||||
|
||||
// Cached price IDs
|
||||
const priceCache: Record<string, string> = {};
|
||||
// SnapAPI product IDs for webhook filtering
|
||||
const snapapiProductIds = new Set<string>();
|
||||
// Provisioned session IDs (dedup)
|
||||
const provisionedSessions = new Set<string>();
|
||||
|
||||
async function getOrCreatePrice(name: string, amount: number, description: string): Promise<string> {
|
||||
if (priceCache[name]) return priceCache[name];
|
||||
|
||||
// Search for existing product by name
|
||||
const products = await stripe.products.search({ query: `name:"${name}"` });
|
||||
let product: Stripe.Product;
|
||||
|
||||
if (products.data.length > 0) {
|
||||
product = products.data[0];
|
||||
logger.info({ productId: product.id, name }, "Found existing Stripe product");
|
||||
} else {
|
||||
product = await stripe.products.create({ name, description });
|
||||
logger.info({ productId: product.id, name }, "Created Stripe product");
|
||||
}
|
||||
|
||||
snapapiProductIds.add(product.id);
|
||||
|
||||
// Check for existing price
|
||||
const prices = await stripe.prices.list({ product: product.id, active: true, limit: 1 });
|
||||
if (prices.data.length > 0 && prices.data[0].unit_amount === amount) {
|
||||
priceCache[name] = prices.data[0].id;
|
||||
return prices.data[0].id;
|
||||
}
|
||||
|
||||
const price = await stripe.prices.create({
|
||||
product: product.id,
|
||||
unit_amount: amount,
|
||||
currency: "eur",
|
||||
recurring: { interval: "month" },
|
||||
});
|
||||
|
||||
priceCache[name] = price.id;
|
||||
logger.info({ priceId: price.id, productId: product.id, name, amount }, "Created Stripe price");
|
||||
return price.id;
|
||||
}
|
||||
|
||||
// Initialize prices on startup
|
||||
async function initPrices() {
|
||||
for (const [key, plan] of Object.entries(PLANS)) {
|
||||
try {
|
||||
await getOrCreatePrice(plan.name, plan.amount, plan.description);
|
||||
} catch (err) {
|
||||
logger.error({ err, plan: key }, "Failed to init price");
|
||||
}
|
||||
}
|
||||
logger.info({ productIds: [...snapapiProductIds], prices: { ...priceCache } }, "SnapAPI Stripe products initialized");
|
||||
}
|
||||
|
||||
initPrices().catch(err => logger.error({ err }, "Failed to initialize Stripe prices"));
|
||||
|
||||
// Helper: check if event belongs to SnapAPI
|
||||
async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
||||
try {
|
||||
let productId: string | undefined;
|
||||
const obj = (event.data as any).object;
|
||||
|
||||
if (event.type === "customer.updated") return true; // Can't filter by product
|
||||
|
||||
// For checkout.session.completed
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = obj as Stripe.Checkout.Session;
|
||||
if (session.subscription) {
|
||||
const sub = await stripe.subscriptions.retrieve(session.subscription as string, { expand: ["items.data.price.product"] });
|
||||
const item = sub.items.data[0];
|
||||
const prod = item?.price?.product;
|
||||
productId = typeof prod === "string" ? prod : prod?.id;
|
||||
}
|
||||
}
|
||||
|
||||
// For subscription events
|
||||
if (event.type.startsWith("customer.subscription.")) {
|
||||
const sub = obj as Stripe.Subscription;
|
||||
const item = sub.items?.data?.[0];
|
||||
if (item) {
|
||||
const prod = item.price?.product;
|
||||
productId = typeof prod === "string" ? prod : (prod as any)?.id;
|
||||
// If product not expanded, fetch it
|
||||
if (!productId && item.price?.id) {
|
||||
const price = await stripe.prices.retrieve(item.price.id);
|
||||
productId = typeof price.product === "string" ? price.product : (price.product as any)?.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!productId) return false;
|
||||
if (productId === DOCFAST_PRODUCT_ID) return false;
|
||||
return snapapiProductIds.has(productId);
|
||||
} catch (err) {
|
||||
logger.error({ err, eventType: event.type }, "Error checking if SnapAPI event");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /v1/billing/checkout
|
||||
router.post("/checkout", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { plan } = req.body;
|
||||
if (!plan || !PLANS[plan]) {
|
||||
return res.status(400).json({ error: "Invalid plan. Choose: starter, pro, business" });
|
||||
}
|
||||
|
||||
const planDef = PLANS[plan];
|
||||
const priceId = await getOrCreatePrice(planDef.name, planDef.amount, planDef.description);
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${BASE_URL}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${BASE_URL}/#pricing`,
|
||||
metadata: { plan, service: "snapapi" },
|
||||
});
|
||||
|
||||
res.json({ url: session.url });
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Checkout error");
|
||||
res.status(500).json({ error: "Failed to create checkout session" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /v1/billing/success
|
||||
router.get("/success", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const sessionId = req.query.session_id as string;
|
||||
if (!sessionId) return res.status(400).send("Missing session_id");
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"] });
|
||||
const email = session.customer_details?.email || session.customer_email || "unknown@snapapi.eu";
|
||||
const plan = session.metadata?.plan || "starter";
|
||||
const tier = PLANS[plan]?.tier || "starter";
|
||||
const customerId = typeof session.customer === "string" ? session.customer : (session.customer as any)?.id;
|
||||
|
||||
let apiKey: string;
|
||||
if (provisionedSessions.has(sessionId)) {
|
||||
// Already provisioned — look up key
|
||||
apiKey = "(already provisioned — check your email or contact support)";
|
||||
} else {
|
||||
provisionedSessions.add(sessionId);
|
||||
const keyEntry = await createPaidKey(email, tier, customerId);
|
||||
apiKey = keyEntry.key;
|
||||
}
|
||||
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Welcome to SnapAPI!</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0a;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#141414;border:1px solid #2a2a2a;border-radius:16px;padding:48px;max-width:520px;width:90%;text-align:center}
|
||||
h1{font-size:1.8rem;margin-bottom:8px;color:#fff}
|
||||
.plan{color:#4ecb71;font-size:1.1rem;margin-bottom:24px}
|
||||
.key-box{background:#0a0a0a;border:1px solid #333;border-radius:8px;padding:16px;margin:24px 0;word-break:break-all;font-family:monospace;font-size:.95rem;color:#4ecb71;position:relative}
|
||||
.copy-btn{background:#4ecb71;color:#0a0a0a;border:none;padding:10px 24px;border-radius:8px;font-weight:600;cursor:pointer;margin-top:16px;font-size:.95rem}
|
||||
.copy-btn:hover{background:#3db85e}
|
||||
.note{color:#888;font-size:.85rem;margin-top:24px;line-height:1.6}
|
||||
a{color:#4ecb71}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>🎉 Welcome to SnapAPI!</h1>
|
||||
<div class="plan">${tier.charAt(0).toUpperCase() + tier.slice(1)} Plan</div>
|
||||
<p>Your API key is ready:</p>
|
||||
<div class="key-box" id="key">${apiKey}</div>
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('key').textContent);this.textContent='Copied!'">Copy API Key</button>
|
||||
<div class="note">
|
||||
Save this key — it won't be shown again.<br>
|
||||
Use it with <code>X-API-Key</code> header or <code>?key=</code> param.<br><br>
|
||||
<a href="/docs">View API Documentation →</a>
|
||||
</div>
|
||||
</div></body></html>`);
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Success page error");
|
||||
res.status(500).send("Something went wrong. Please contact support.");
|
||||
}
|
||||
});
|
||||
|
||||
// POST /v1/billing/webhook
|
||||
router.post("/webhook", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const sig = req.headers["stripe-signature"] as string;
|
||||
if (!sig) return res.status(400).send("Missing signature");
|
||||
|
||||
const event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
|
||||
|
||||
// Filter: only process SnapAPI events
|
||||
if (event.type !== "customer.updated") {
|
||||
const isOurs = await isSnapAPIEvent(event);
|
||||
if (!isOurs) {
|
||||
logger.info({ eventType: event.type, eventId: event.id }, "Ignoring non-SnapAPI event");
|
||||
return res.json({ received: true, ignored: true });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ eventType: event.type, eventId: event.id }, "Processing webhook");
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = (event.data as any).object as Stripe.Checkout.Session;
|
||||
const sessionId = session.id;
|
||||
if (!provisionedSessions.has(sessionId)) {
|
||||
provisionedSessions.add(sessionId);
|
||||
const email = session.customer_details?.email || session.customer_email || "unknown@snapapi.eu";
|
||||
const plan = session.metadata?.plan || "starter";
|
||||
const tier = PLANS[plan]?.tier || "starter";
|
||||
const customerId = typeof session.customer === "string" ? session.customer : (session.customer as any)?.id;
|
||||
await createPaidKey(email, tier, customerId);
|
||||
logger.info({ email, tier, customerId }, "Provisioned key via webhook");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "customer.subscription.updated": {
|
||||
const sub = (event.data as any).object as Stripe.Subscription;
|
||||
const customerId = typeof sub.customer === "string" ? sub.customer : (sub.customer as any)?.id;
|
||||
if (sub.status === "canceled" || sub.status === "past_due" || sub.status === "unpaid") {
|
||||
await downgradeByCustomer(customerId);
|
||||
logger.info({ customerId, status: sub.status }, "Downgraded customer");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "customer.subscription.deleted": {
|
||||
const sub = (event.data as any).object as Stripe.Subscription;
|
||||
const customerId = typeof sub.customer === "string" ? sub.customer : (sub.customer as any)?.id;
|
||||
await downgradeByCustomer(customerId);
|
||||
logger.info({ customerId }, "Subscription deleted, downgraded");
|
||||
break;
|
||||
}
|
||||
|
||||
case "customer.updated": {
|
||||
const customer = (event.data as any).object as Stripe.Customer;
|
||||
if (customer.email) {
|
||||
await updateEmailByCustomer(customer.id, customer.email);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Webhook error");
|
||||
res.status(400).send("Webhook error: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as billingRouter };
|
||||
14
src/routes/health.ts
Normal file
14
src/routes/health.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Router } from "express";
|
||||
import { getPoolStats } from "../services/browser.js";
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
healthRouter.get("/", (_req, res) => {
|
||||
const pool = getPoolStats();
|
||||
res.json({
|
||||
status: "ok",
|
||||
version: "0.1.0",
|
||||
uptime: process.uptime(),
|
||||
browser: pool,
|
||||
});
|
||||
});
|
||||
68
src/routes/playground.ts
Normal file
68
src/routes/playground.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Router } from "express";
|
||||
import { takeScreenshot } from "../services/screenshot.js";
|
||||
import { addWatermark } from "../services/watermark.js";
|
||||
import logger from "../services/logger.js";
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
||||
export const playgroundRouter = Router();
|
||||
|
||||
// 5 requests per hour per IP
|
||||
const playgroundLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Playground rate limit exceeded (5 requests/hour). Get an API key for unlimited access.", upgrade: "https://snapapi.eu/#pricing" },
|
||||
keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown",
|
||||
});
|
||||
|
||||
playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
||||
const { url, format, width, height } = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({ error: "Missing required parameter: url" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce reasonable limits for playground
|
||||
const safeWidth = Math.min(Math.max(parseInt(width, 10) || 1280, 320), 1920);
|
||||
const safeHeight = Math.min(Math.max(parseInt(height, 10) || 800, 200), 1080);
|
||||
const safeFormat = ["png", "jpeg", "webp"].includes(format) ? format : "png";
|
||||
|
||||
try {
|
||||
const result = await takeScreenshot({
|
||||
url,
|
||||
format: safeFormat as "png" | "jpeg" | "webp",
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
fullPage: false,
|
||||
quality: safeFormat === "png" ? undefined : 70,
|
||||
deviceScale: 1,
|
||||
});
|
||||
|
||||
// Add watermark
|
||||
const watermarked = await addWatermark(result.buffer, safeWidth, safeHeight);
|
||||
|
||||
res.setHeader("Content-Type", result.contentType);
|
||||
res.setHeader("Content-Length", watermarked.length);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("X-Playground", "true");
|
||||
res.send(watermarked);
|
||||
} catch (err: any) {
|
||||
logger.error({ err: err.message, url }, "Playground screenshot failed");
|
||||
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(503).json({ error: "Service busy. Try again shortly." });
|
||||
return;
|
||||
}
|
||||
if (err.message === "SCREENSHOT_TIMEOUT") {
|
||||
res.status(504).json({ error: "Screenshot timed out." });
|
||||
return;
|
||||
}
|
||||
if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "Screenshot failed" });
|
||||
}
|
||||
});
|
||||
50
src/routes/screenshot.ts
Normal file
50
src/routes/screenshot.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Router } from "express";
|
||||
import { takeScreenshot } from "../services/screenshot.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
export const screenshotRouter = Router();
|
||||
|
||||
screenshotRouter.post("/", async (req: any, res) => {
|
||||
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay } = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({ error: "Missing required parameter: url" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await takeScreenshot({
|
||||
url,
|
||||
format: format || "png",
|
||||
width: width ? parseInt(width, 10) : undefined,
|
||||
height: height ? parseInt(height, 10) : undefined,
|
||||
fullPage: fullPage === true || fullPage === "true",
|
||||
quality: quality ? parseInt(quality, 10) : undefined,
|
||||
waitForSelector,
|
||||
deviceScale: deviceScale ? parseFloat(deviceScale) : undefined,
|
||||
delay: delay ? parseInt(delay, 10) : undefined,
|
||||
});
|
||||
|
||||
res.setHeader("Content-Type", result.contentType);
|
||||
res.setHeader("Content-Length", result.buffer.length);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.send(result.buffer);
|
||||
} catch (err: any) {
|
||||
logger.error({ err: err.message, url }, "Screenshot failed");
|
||||
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(503).json({ error: "Service busy. Try again shortly." });
|
||||
return;
|
||||
}
|
||||
if (err.message === "SCREENSHOT_TIMEOUT") {
|
||||
res.status(504).json({ error: "Screenshot timed out. The page may be too slow to load." });
|
||||
return;
|
||||
}
|
||||
if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Screenshot failed", details: err.message });
|
||||
}
|
||||
});
|
||||
29
src/routes/signup.ts
Normal file
29
src/routes/signup.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Router } from "express";
|
||||
import { createKey } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
export const signupRouter = Router();
|
||||
|
||||
// Simple signup: email → instant API key (no verification for now)
|
||||
signupRouter.post("/free", async (req, res) => {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email || typeof email !== "string" || !email.includes("@")) {
|
||||
res.status(400).json({ error: "Valid email required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await createKey(email.toLowerCase().trim(), "free");
|
||||
logger.info({ email: email.slice(0, 3) + "***" }, "Free signup");
|
||||
res.json({
|
||||
apiKey: key.key,
|
||||
tier: "free",
|
||||
limit: 100,
|
||||
message: "Your API key is ready! 100 free screenshots/month.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Signup failed");
|
||||
res.status(500).json({ error: "Signup failed" });
|
||||
}
|
||||
});
|
||||
9
src/routes/status.ts
Normal file
9
src/routes/status.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Router } from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const router = Router();
|
||||
router.get("/", (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../../public/status.html"));
|
||||
});
|
||||
export { router as statusRouter };
|
||||
144
src/services/browser.ts
Normal file
144
src/services/browser.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import puppeteer, { Browser, Page } from "puppeteer";
|
||||
import logger from "./logger.js";
|
||||
|
||||
const BROWSER_COUNT = parseInt(process.env.BROWSER_COUNT || "2", 10);
|
||||
const PAGES_PER_BROWSER = parseInt(process.env.PAGES_PER_BROWSER || "4", 10);
|
||||
const RESTART_AFTER = 500;
|
||||
const RESTART_AFTER_MS = 60 * 60 * 1000;
|
||||
|
||||
interface BrowserInstance {
|
||||
browser: Browser;
|
||||
availablePages: Page[];
|
||||
jobCount: number;
|
||||
lastRestartTime: number;
|
||||
restarting: boolean;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const instances: BrowserInstance[] = [];
|
||||
const waitingQueue: Array<{ resolve: (v: { page: Page; instance: BrowserInstance }) => void }> = [];
|
||||
let roundRobinIndex = 0;
|
||||
|
||||
export function getPoolStats() {
|
||||
const totalAvailable = instances.reduce((s, i) => s + i.availablePages.length, 0);
|
||||
return {
|
||||
browsers: instances.length,
|
||||
pagesPerBrowser: PAGES_PER_BROWSER,
|
||||
totalPages: instances.length * PAGES_PER_BROWSER,
|
||||
availablePages: totalAvailable,
|
||||
queueDepth: waitingQueue.length,
|
||||
totalJobs: instances.reduce((s, i) => s + i.jobCount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function recyclePage(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.goto("about:blank", { timeout: 5000 }).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function createPages(b: Browser, count: number): Promise<Page[]> {
|
||||
const pages: Page[] = [];
|
||||
for (let i = 0; i < count; i++) pages.push(await b.newPage());
|
||||
return pages;
|
||||
}
|
||||
|
||||
function pickInstance(): BrowserInstance | null {
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
const idx = (roundRobinIndex + i) % instances.length;
|
||||
const inst = instances[idx];
|
||||
if (inst.availablePages.length > 0 && !inst.restarting) {
|
||||
roundRobinIndex = (idx + 1) % instances.length;
|
||||
return inst;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function acquirePage(): Promise<{ page: Page; instance: BrowserInstance }> {
|
||||
for (const inst of instances) {
|
||||
if (!inst.restarting && (inst.jobCount >= RESTART_AFTER || Date.now() - inst.lastRestartTime >= RESTART_AFTER_MS)) {
|
||||
scheduleRestart(inst);
|
||||
}
|
||||
}
|
||||
|
||||
const inst = pickInstance();
|
||||
if (inst) {
|
||||
return { page: inst.availablePages.pop()!, instance: inst };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const idx = waitingQueue.findIndex(w => w.resolve === resolve);
|
||||
if (idx >= 0) waitingQueue.splice(idx, 1);
|
||||
reject(new Error("QUEUE_FULL"));
|
||||
}, 30_000);
|
||||
waitingQueue.push({ resolve: (v) => { clearTimeout(timer); resolve(v); } });
|
||||
});
|
||||
}
|
||||
|
||||
export function releasePage(page: Page, inst: BrowserInstance): void {
|
||||
inst.jobCount++;
|
||||
const waiter = waitingQueue.shift();
|
||||
if (waiter) {
|
||||
recyclePage(page).then(() => waiter.resolve({ page, instance: inst })).catch(() => {
|
||||
waitingQueue.unshift(waiter);
|
||||
});
|
||||
return;
|
||||
}
|
||||
recyclePage(page).then(() => inst.availablePages.push(page)).catch(() => {});
|
||||
}
|
||||
|
||||
async function scheduleRestart(inst: BrowserInstance): Promise<void> {
|
||||
if (inst.restarting) return;
|
||||
inst.restarting = true;
|
||||
logger.info(`Scheduling browser ${inst.id} restart`);
|
||||
|
||||
// Wait for pages to drain (max 30s)
|
||||
await Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
const check = () => {
|
||||
if (inst.availablePages.length === PAGES_PER_BROWSER) resolve();
|
||||
else setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
}),
|
||||
new Promise<void>(r => setTimeout(r, 30000)),
|
||||
]);
|
||||
|
||||
for (const page of inst.availablePages) await page.close().catch(() => {});
|
||||
inst.availablePages.length = 0;
|
||||
try { await inst.browser.close(); } catch {}
|
||||
|
||||
inst.browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
inst.availablePages.push(...await createPages(inst.browser, PAGES_PER_BROWSER));
|
||||
inst.jobCount = 0;
|
||||
inst.lastRestartTime = Date.now();
|
||||
inst.restarting = false;
|
||||
logger.info(`Browser ${inst.id} restarted`);
|
||||
}
|
||||
|
||||
export async function initBrowser(): Promise<void> {
|
||||
for (let i = 0; i < BROWSER_COUNT; i++) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const pages = await createPages(browser, PAGES_PER_BROWSER);
|
||||
instances.push({ browser, availablePages: pages, jobCount: 0, lastRestartTime: Date.now(), restarting: false, id: i });
|
||||
}
|
||||
logger.info(`Browser pool ready (${BROWSER_COUNT}×${PAGES_PER_BROWSER} = ${BROWSER_COUNT * PAGES_PER_BROWSER} pages)`);
|
||||
}
|
||||
|
||||
export async function closeBrowser(): Promise<void> {
|
||||
for (const inst of instances) {
|
||||
for (const page of inst.availablePages) await page.close().catch(() => {});
|
||||
await inst.browser.close().catch(() => {});
|
||||
}
|
||||
instances.length = 0;
|
||||
}
|
||||
100
src/services/db.ts
Normal file
100
src/services/db.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import pg from "pg";
|
||||
import logger from "./logger.js";
|
||||
const { Pool } = pg;
|
||||
|
||||
const TRANSIENT_ERRORS = new Set([
|
||||
"ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT",
|
||||
"57P01", "57P02", "57P03", "08006", "08003", "08001",
|
||||
]);
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DATABASE_HOST || "main-db-pooler.postgres.svc",
|
||||
port: parseInt(process.env.DATABASE_PORT || "5432", 10),
|
||||
database: process.env.DATABASE_NAME || "snapapi",
|
||||
user: process.env.DATABASE_USER || "docfast",
|
||||
password: process.env.DATABASE_PASSWORD || "docfast",
|
||||
max: 10,
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 10000,
|
||||
});
|
||||
|
||||
pool.on("error", (err) => {
|
||||
logger.error({ err }, "Unexpected error on idle PostgreSQL client");
|
||||
});
|
||||
|
||||
function isTransientError(err: any): boolean {
|
||||
if (!err) return false;
|
||||
const code = err.code || "";
|
||||
const msg = (err.message || "").toLowerCase();
|
||||
if (TRANSIENT_ERRORS.has(code)) return true;
|
||||
if (msg.includes("no available server") || msg.includes("connection terminated") || msg.includes("connection refused")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function queryWithRetry(text: string, params?: any[], maxRetries = 3): Promise<pg.QueryResult> {
|
||||
let lastError: any;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
let client: pg.PoolClient | undefined;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
const result = await client.query(text, params);
|
||||
client.release();
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
if (client) try { client.release(true); } catch (_) {}
|
||||
lastError = err;
|
||||
if (!isTransientError(err) || attempt === maxRetries) throw err;
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000);
|
||||
logger.warn({ err: err.message, attempt: attempt + 1 }, "Transient DB error, retrying...");
|
||||
await new Promise(r => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function connectWithRetry(maxRetries = 3): Promise<pg.PoolClient> {
|
||||
let lastError: any;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
await client.query("SELECT 1");
|
||||
return client;
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
if (!isTransientError(err) || attempt === maxRetries) throw err;
|
||||
await new Promise(r => setTimeout(r, Math.min(1000 * Math.pow(2, attempt), 5000)));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
const client = await connectWithRetry();
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
key TEXT PRIMARY KEY,
|
||||
tier TEXT NOT NULL DEFAULT 'free',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
stripe_customer_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage (
|
||||
key TEXT NOT NULL,
|
||||
month_key TEXT NOT NULL,
|
||||
count INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (key, month_key)
|
||||
);
|
||||
`);
|
||||
logger.info("PostgreSQL tables initialized");
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export { pool };
|
||||
export default pool;
|
||||
207
src/services/keys.ts
Normal file
207
src/services/keys.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { randomBytes } from "crypto";
|
||||
import logger from "./logger.js";
|
||||
import { queryWithRetry } from "./db.js";
|
||||
|
||||
export interface ApiKey {
|
||||
key: string;
|
||||
tier: "free" | "starter" | "pro" | "business";
|
||||
email: string;
|
||||
createdAt: string;
|
||||
stripeCustomerId?: string;
|
||||
}
|
||||
|
||||
let keysCache: ApiKey[] = [];
|
||||
|
||||
export async function loadKeys(): Promise<void> {
|
||||
try {
|
||||
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys");
|
||||
keysCache = result.rows.map(r => ({
|
||||
key: r.key,
|
||||
tier: r.tier,
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to load keys");
|
||||
keysCache = [];
|
||||
}
|
||||
|
||||
// Seed keys from env
|
||||
const envKeys = process.env.API_KEYS?.split(",").map(k => k.trim()).filter(Boolean) || [];
|
||||
for (const k of envKeys) {
|
||||
if (!keysCache.find(e => e.key === k)) {
|
||||
const entry: ApiKey = { key: k, tier: "business", email: "admin@snapapi.dev", createdAt: new Date().toISOString() };
|
||||
keysCache.push(entry);
|
||||
await queryWithRetry(
|
||||
`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (key) DO NOTHING`,
|
||||
[k, "business", entry.email, entry.createdAt]
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache-aside: check DB if key not in memory (multi-replica support)
|
||||
async function fetchKeyFromDb(key: string): Promise<ApiKey | undefined> {
|
||||
try {
|
||||
const result = await queryWithRetry(
|
||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE key = $1",
|
||||
[key]
|
||||
);
|
||||
if (result.rows.length === 0) return undefined;
|
||||
const r = result.rows[0];
|
||||
const entry: ApiKey = {
|
||||
key: r.key,
|
||||
tier: r.tier,
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
// Add to local cache
|
||||
if (!keysCache.find(k => k.key === entry.key)) {
|
||||
keysCache.push(entry);
|
||||
}
|
||||
return entry;
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to fetch key from DB");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isValidKey(key: string): Promise<boolean> {
|
||||
if (keysCache.some(k => k.key === key)) return true;
|
||||
const fetched = await fetchKeyFromDb(key);
|
||||
return fetched !== undefined;
|
||||
}
|
||||
|
||||
export async function getKeyInfo(key: string): Promise<ApiKey | undefined> {
|
||||
const cached = keysCache.find(k => k.key === key);
|
||||
if (cached) return cached;
|
||||
return fetchKeyFromDb(key);
|
||||
}
|
||||
|
||||
function generateKey(): string {
|
||||
return `snap_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export async function createKey(email: string, tier: ApiKey["tier"] = "free"): Promise<ApiKey> {
|
||||
// For free tier, check DB too (another pod might have created it)
|
||||
if (tier === "free") {
|
||||
const existing = keysCache.find(k => k.email === email && k.tier === "free");
|
||||
if (existing) return existing;
|
||||
// Also check DB
|
||||
try {
|
||||
const result = await queryWithRetry(
|
||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1 AND tier = $2",
|
||||
[email, "free"]
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
const r = result.rows[0];
|
||||
const entry: ApiKey = {
|
||||
key: r.key, tier: r.tier, email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
if (!keysCache.find(k => k.key === entry.key)) keysCache.push(entry);
|
||||
return entry;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const entry: ApiKey = {
|
||||
key: generateKey(),
|
||||
tier,
|
||||
email,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await queryWithRetry(
|
||||
"INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)",
|
||||
[entry.key, entry.tier, entry.email, entry.createdAt]
|
||||
);
|
||||
keysCache.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function getAllKeys(): ApiKey[] {
|
||||
return [...keysCache];
|
||||
}
|
||||
|
||||
export function getTierLimit(tier: string): number {
|
||||
switch (tier) {
|
||||
case "free": return 100;
|
||||
case "starter": return 1000;
|
||||
case "pro": return 5000;
|
||||
case "business": return 25000;
|
||||
default: return 100;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPaidKey(email: string, tier: "starter" | "pro" | "business", stripeCustomerId?: string): Promise<ApiKey> {
|
||||
// Check if customer already has a key
|
||||
if (stripeCustomerId) {
|
||||
try {
|
||||
const result = await queryWithRetry(
|
||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1",
|
||||
[stripeCustomerId]
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
const r = result.rows[0];
|
||||
const entry: ApiKey = {
|
||||
key: r.key, tier: r.tier, email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
// Update tier if upgrading
|
||||
if (entry.tier !== tier) {
|
||||
await queryWithRetry("UPDATE api_keys SET tier = $1 WHERE stripe_customer_id = $2", [tier, stripeCustomerId]);
|
||||
entry.tier = tier;
|
||||
const cached = keysCache.find(k => k.key === entry.key);
|
||||
if (cached) cached.tier = tier;
|
||||
}
|
||||
if (!keysCache.find(k => k.key === entry.key)) keysCache.push(entry);
|
||||
return entry;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const entry: ApiKey = {
|
||||
key: generateKey(),
|
||||
tier,
|
||||
email,
|
||||
createdAt: new Date().toISOString(),
|
||||
stripeCustomerId,
|
||||
};
|
||||
|
||||
await queryWithRetry(
|
||||
"INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)",
|
||||
[entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId || null]
|
||||
);
|
||||
keysCache.push(entry);
|
||||
logger.info({ email, tier, stripeCustomerId }, "Created paid API key");
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function downgradeByCustomer(customerId: string): Promise<void> {
|
||||
await queryWithRetry(
|
||||
"UPDATE api_keys SET tier = 'free', stripe_customer_id = NULL WHERE stripe_customer_id = $1",
|
||||
[customerId]
|
||||
);
|
||||
for (const k of keysCache) {
|
||||
if (k.stripeCustomerId === customerId) {
|
||||
k.tier = "free";
|
||||
k.stripeCustomerId = undefined;
|
||||
}
|
||||
}
|
||||
logger.info({ customerId }, "Downgraded customer to free");
|
||||
}
|
||||
|
||||
export async function updateEmailByCustomer(customerId: string, newEmail: string): Promise<void> {
|
||||
await queryWithRetry(
|
||||
"UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2",
|
||||
[newEmail, customerId]
|
||||
);
|
||||
for (const k of keysCache) {
|
||||
if (k.stripeCustomerId === customerId) k.email = newEmail;
|
||||
}
|
||||
}
|
||||
3
src/services/logger.ts
Normal file
3
src/services/logger.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import pino from "pino";
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
|
||||
export default logger;
|
||||
74
src/services/screenshot.ts
Normal file
74
src/services/screenshot.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Page } from "puppeteer";
|
||||
import { acquirePage, releasePage } from "./browser.js";
|
||||
import { validateUrl } from "./ssrf.js";
|
||||
import logger from "./logger.js";
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
url: string;
|
||||
format?: "png" | "jpeg" | "webp";
|
||||
width?: number;
|
||||
height?: number;
|
||||
fullPage?: boolean;
|
||||
quality?: number;
|
||||
waitForSelector?: string;
|
||||
deviceScale?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const MAX_WIDTH = 3840;
|
||||
const MAX_HEIGHT = 2160;
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
export async function takeScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult> {
|
||||
// Validate URL for SSRF
|
||||
await validateUrl(opts.url);
|
||||
|
||||
const format = opts.format || "png";
|
||||
const width = Math.min(opts.width || 1280, MAX_WIDTH);
|
||||
const height = Math.min(opts.height || 800, MAX_HEIGHT);
|
||||
const fullPage = opts.fullPage ?? false;
|
||||
const quality = format === "png" ? undefined : Math.min(Math.max(opts.quality || 80, 1), 100);
|
||||
const deviceScale = Math.min(opts.deviceScale || 1, 3);
|
||||
|
||||
const { page, instance } = await acquirePage();
|
||||
|
||||
try {
|
||||
await page.setViewport({ width, height, deviceScaleFactor: deviceScale });
|
||||
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(opts.url, { waitUntil: "networkidle2", timeout: 20_000 });
|
||||
|
||||
if (opts.waitForSelector) {
|
||||
await page.waitForSelector(opts.waitForSelector, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
if (opts.delay && opts.delay > 0) {
|
||||
await new Promise(r => setTimeout(r, Math.min(opts.delay!, 5000)));
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("SCREENSHOT_TIMEOUT")), TIMEOUT_MS)),
|
||||
]);
|
||||
|
||||
const screenshotOpts: any = {
|
||||
type: format === "webp" ? "webp" : format,
|
||||
fullPage,
|
||||
encoding: "binary",
|
||||
};
|
||||
if (quality !== undefined) screenshotOpts.quality = quality;
|
||||
|
||||
const result = await page.screenshot(screenshotOpts);
|
||||
const buffer = Buffer.from(result as unknown as ArrayBuffer);
|
||||
|
||||
const contentType = format === "png" ? "image/png" : format === "jpeg" ? "image/jpeg" : "image/webp";
|
||||
|
||||
return { buffer, contentType };
|
||||
} finally {
|
||||
releasePage(page, instance);
|
||||
}
|
||||
}
|
||||
65
src/services/ssrf.ts
Normal file
65
src/services/ssrf.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { lookup } from "dns/promises";
|
||||
import logger from "./logger.js";
|
||||
|
||||
// Block private, loopback, link-local, metadata IPs
|
||||
const BLOCKED_RANGES = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^::1$/,
|
||||
/^fe80:/i,
|
||||
/^fc00:/i,
|
||||
/^fd00:/i,
|
||||
];
|
||||
|
||||
const BLOCKED_HOSTS = [
|
||||
/\.svc$/,
|
||||
/\.svc\./,
|
||||
/\.cluster\.local$/,
|
||||
/\.internal$/,
|
||||
/^localhost$/,
|
||||
/^kubernetes/,
|
||||
];
|
||||
|
||||
export async function validateUrl(urlStr: string): Promise<{ hostname: string; resolvedIp: string }> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(urlStr);
|
||||
} catch {
|
||||
throw new Error("Invalid URL");
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
throw new Error("Only HTTP and HTTPS URLs are allowed");
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
// Check blocked hostnames
|
||||
for (const pattern of BLOCKED_HOSTS) {
|
||||
if (pattern.test(hostname)) {
|
||||
throw new Error("URL hostname is not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve DNS and check IP
|
||||
let ip: string;
|
||||
try {
|
||||
const result = await lookup(hostname);
|
||||
ip = result.address;
|
||||
} catch {
|
||||
throw new Error("Could not resolve hostname");
|
||||
}
|
||||
|
||||
for (const pattern of BLOCKED_RANGES) {
|
||||
if (pattern.test(ip)) {
|
||||
logger.warn({ hostname, ip }, "SSRF attempt blocked");
|
||||
throw new Error("URL resolves to a blocked IP range");
|
||||
}
|
||||
}
|
||||
|
||||
return { hostname, resolvedIp: ip };
|
||||
}
|
||||
62
src/services/watermark.ts
Normal file
62
src/services/watermark.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Adds a text watermark to a PNG/JPEG/WebP screenshot buffer.
|
||||
* Uses pure SVG overlay composited via Puppeteer's page.evaluate or
|
||||
* a simpler approach: we re-render an HTML page with the image + watermark overlay.
|
||||
*
|
||||
* Since we already have Puppeteer, the simplest reliable approach is to
|
||||
* render an HTML page with the screenshot as background + CSS text overlay.
|
||||
* But that's expensive (double render). Instead, use a lightweight approach:
|
||||
* encode watermark text directly into the PNG using canvas-less SVG trick.
|
||||
*
|
||||
* Simplest production approach: use sharp if available, or fallback to
|
||||
* Puppeteer overlay. Since we don't want to add sharp (large native dep),
|
||||
* we'll use Puppeteer to composite.
|
||||
*/
|
||||
import { acquirePage, releasePage } from "./browser.js";
|
||||
|
||||
export async function addWatermark(imageBuffer: Buffer, width: number, height: number): Promise<Buffer> {
|
||||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
const b64 = imageBuffer.toString("base64");
|
||||
const dataUrl = `data:image/png;base64,${b64}`;
|
||||
|
||||
await page.setViewport({ width, height });
|
||||
|
||||
// Render the image with a watermark overlay
|
||||
await page.setContent(`
|
||||
<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
* { margin: 0; padding: 0; }
|
||||
body { width: ${width}px; height: ${height}px; position: relative; overflow: hidden; }
|
||||
img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.watermark {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.watermark-text {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: ${Math.max(width / 20, 24)}px;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.5);
|
||||
transform: rotate(-30deg);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 2px;
|
||||
user-select: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style></head><body>
|
||||
<img src="${dataUrl}">
|
||||
<div class="watermark">
|
||||
<div class="watermark-text">snapapi.eu — upgrade for clean screenshots</div>
|
||||
</div>
|
||||
</body></html>
|
||||
`, { waitUntil: "load" });
|
||||
|
||||
const result = await page.screenshot({ type: "png", encoding: "binary" });
|
||||
return Buffer.from(result as unknown as ArrayBuffer);
|
||||
} finally {
|
||||
releasePage(page, instance);
|
||||
}
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue