fix: privacy 404 + enhanced playground controls
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m7s

BUG-010: Add 301 redirects for clean URLs (/privacy → /privacy.html etc.)
and fix inconsistent href links across legal pages.

FEATURE: Enhanced playground with fullPage, quality, deviceScale,
waitUntil, and waitForSelector controls for better API evaluation.
This commit is contained in:
SnapAPI Agent 2026-02-22 08:52:32 +00:00
parent d20fbbfe2e
commit db1fa8d506
6 changed files with 79 additions and 22 deletions

View file

@ -162,9 +162,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
</div> </div>
<div class="footer-col"> <div class="footer-col">
<h5>Legal</h5> <h5>Legal</h5>
<a href="/impressum">Impressum</a> <a href="/impressum.html">Impressum</a>
<a href="/privacy">Privacy Policy</a> <a href="/privacy.html">Privacy Policy</a>
<a href="/terms">Terms of Service</a> <a href="/terms.html">Terms of Service</a>
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">

View file

@ -302,6 +302,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
<label>URL to capture</label> <label>URL to capture</label>
<input type="url" id="pg-url" value="https://example.com" placeholder="https://example.com"> <input type="url" id="pg-url" value="https://example.com" placeholder="https://example.com">
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div> <div>
<label>Format</label> <label>Format</label>
<select id="pg-format"> <select id="pg-format">
@ -310,6 +311,11 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
<option value="webp">WebP</option> <option value="webp">WebP</option>
</select> </select>
</div> </div>
<div>
<label>Quality</label>
<input type="number" id="pg-quality" value="80" min="1" max="100" title="JPEG/WebP quality (1-100). Ignored for PNG.">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div> <div>
<label>Width</label> <label>Width</label>
@ -320,6 +326,34 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
<input type="number" id="pg-height" value="800" min="200" max="1080"> <input type="number" id="pg-height" value="800" min="200" max="1080">
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<label>Device Scale</label>
<select id="pg-scale">
<option value="1">1x</option>
<option value="2">2x (Retina)</option>
<option value="3">3x</option>
</select>
</div>
<div>
<label>Wait Until</label>
<select id="pg-waituntil">
<option value="domcontentloaded">DOM Ready</option>
<option value="load">Page Load</option>
<option value="networkidle0">Network Idle</option>
<option value="networkidle2">Network Idle 2</option>
</select>
</div>
</div>
<div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;text-transform:none;letter-spacing:0;font-size:.9rem">
<input type="checkbox" id="pg-fullpage" style="width:auto;padding:0;accent-color:var(--primary)"> Capture full page
</label>
</div>
<div>
<label>Wait for Selector <span style="font-weight:400;text-transform:none;letter-spacing:0">(optional)</span></label>
<input type="text" id="pg-selector" placeholder="#content, .loaded, img" title="CSS selector to wait for before capturing">
</div>
<button class="btn btn-primary" onclick="runPlayground()" id="pg-btn" style="margin-top:auto"> <button class="btn btn-primary" onclick="runPlayground()" id="pg-btn" style="margin-top:auto">
Take Screenshot → Take Screenshot →
</button> </button>
@ -632,8 +666,13 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
async function runPlayground(){ async function runPlayground(){
var url=document.getElementById('pg-url').value; var url=document.getElementById('pg-url').value;
var format=document.getElementById('pg-format').value; var format=document.getElementById('pg-format').value;
var quality=parseInt(document.getElementById('pg-quality').value)||80;
var width=parseInt(document.getElementById('pg-width').value)||1280; var width=parseInt(document.getElementById('pg-width').value)||1280;
var height=parseInt(document.getElementById('pg-height').value)||800; var height=parseInt(document.getElementById('pg-height').value)||800;
var fullPage=document.getElementById('pg-fullpage').checked;
var deviceScale=parseInt(document.getElementById('pg-scale').value)||1;
var waitUntil=document.getElementById('pg-waituntil').value;
var waitForSelector=document.getElementById('pg-selector').value.trim()||undefined;
if(!url){alert('Please enter a URL');return} if(!url){alert('Please enter a URL');return}
var btn=document.getElementById('pg-btn'); var btn=document.getElementById('pg-btn');
@ -646,11 +685,14 @@ async function runPlayground(){
placeholder.style.display='none';result.style.display='none';error.style.display='none'; placeholder.style.display='none';result.style.display='none';error.style.display='none';
loading.style.display='flex'; loading.style.display='flex';
var body={url:url,format:format,width:width,height:height,fullPage:fullPage,quality:quality,deviceScale:deviceScale,waitUntil:waitUntil};
if(waitForSelector)body.waitForSelector=waitForSelector;
try{ try{
var r=await fetch('/v1/playground',{ var r=await fetch('/v1/playground',{
method:'POST', method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body:JSON.stringify({url:url,format:format,width:width,height:height}) body:JSON.stringify(body)
}); });
if(!r.ok){var d=await r.json().catch(function(){return{}});throw new Error(d.error||'HTTP '+r.status)} if(!r.ok){var d=await r.json().catch(function(){return{}});throw new Error(d.error||'HTTP '+r.status)}
var blob=await r.blob(); var blob=await r.blob();

View file

@ -298,9 +298,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
</div> </div>
<div class="footer-col"> <div class="footer-col">
<h5>Legal</h5> <h5>Legal</h5>
<a href="/impressum">Impressum</a> <a href="/impressum.html">Impressum</a>
<a href="/privacy">Privacy Policy</a> <a href="/privacy.html">Privacy Policy</a>
<a href="/terms">Terms of Service</a> <a href="/terms.html">Terms of Service</a>
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">

View file

@ -236,7 +236,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
<h2>6. Data & Privacy</h2> <h2>6. Data & Privacy</h2>
<ul> <ul>
<li>Your privacy is governed by our <a href="/privacy">Privacy Policy</a></li> <li>Your privacy is governed by our <a href="/privacy.html">Privacy Policy</a></li>
<li>All data processing occurs within the European Union</li> <li>All data processing occurs within the European Union</li>
<li>Screenshots are generated and returned immediately (not stored)</li> <li>Screenshots are generated and returned immediately (not stored)</li>
<li>API usage logs retained for billing and security purposes</li> <li>API usage logs retained for billing and security purposes</li>
@ -379,9 +379,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
</div> </div>
<div class="footer-col"> <div class="footer-col">
<h5>Legal</h5> <h5>Legal</h5>
<a href="/impressum">Impressum</a> <a href="/impressum.html">Impressum</a>
<a href="/privacy">Privacy Policy</a> <a href="/privacy.html">Privacy Policy</a>
<a href="/terms">Terms of Service</a> <a href="/terms.html">Terms of Service</a>
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">

View file

@ -122,6 +122,11 @@ app.get("/openapi.json", (_req, res) => {
app.get("/docs", (_req, res) => { app.get("/docs", (_req, res) => {
res.sendFile(path.join(__dirname, "../public/docs.html")); res.sendFile(path.join(__dirname, "../public/docs.html"));
}); });
// Clean URLs for legal pages (redirect /privacy → /privacy.html, etc.)
for (const page of ["privacy", "terms", "impressum", "status"]) {
app.get(`/${page}`, (_req, res) => res.redirect(301, `/${page}.html`));
}
// Static files (landing page) // Static files (landing page)
app.use(express.static(path.join(__dirname, "../public"), { etag: true })); app.use(express.static(path.join(__dirname, "../public"), { etag: true }));

View file

@ -84,7 +84,7 @@ const playgroundLimiter = rateLimit({
* schema: { $ref: "#/components/schemas/Error" } * schema: { $ref: "#/components/schemas/Error" }
*/ */
playgroundRouter.post("/", playgroundLimiter, async (req, res) => { playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
const { url, format, width, height } = req.body; const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, waitUntil } = req.body;
if (!url || typeof url !== "string") { if (!url || typeof url !== "string") {
res.status(400).json({ error: "Missing required parameter: url" }); res.status(400).json({ error: "Missing required parameter: url" });
@ -95,6 +95,14 @@ playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
const safeWidth = Math.min(Math.max(parseInt(width, 10) || 1280, 320), 1920); 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 safeHeight = Math.min(Math.max(parseInt(height, 10) || 800, 200), 1080);
const safeFormat = ["png", "jpeg", "webp"].includes(format) ? format : "png"; const safeFormat = ["png", "jpeg", "webp"].includes(format) ? format : "png";
const safeFullPage = fullPage === true;
const safeQuality = safeFormat === "png" ? undefined : Math.min(Math.max(parseInt(quality, 10) || 80, 1), 100);
const safeDeviceScale = Math.min(Math.max(parseInt(deviceScale, 10) || 1, 1), 3);
const validWaitUntil = ["load", "domcontentloaded", "networkidle0", "networkidle2"];
const safeWaitUntil = validWaitUntil.includes(waitUntil) ? waitUntil : "domcontentloaded";
// Sanitize waitForSelector — allow simple CSS selectors only (no script injection)
const safeWaitForSelector = typeof waitForSelector === "string" && /^[a-zA-Z0-9\s\-_.#\[\]=:"'>,+~()]+$/.test(waitForSelector) && waitForSelector.length <= 200
? waitForSelector : undefined;
try { try {
const result = await takeScreenshot({ const result = await takeScreenshot({
@ -102,9 +110,11 @@ playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
format: safeFormat as "png" | "jpeg" | "webp", format: safeFormat as "png" | "jpeg" | "webp",
width: safeWidth, width: safeWidth,
height: safeHeight, height: safeHeight,
fullPage: false, fullPage: safeFullPage,
quality: safeFormat === "png" ? undefined : 70, quality: safeQuality,
deviceScale: 1, deviceScale: safeDeviceScale,
waitUntil: safeWaitUntil as any,
waitForSelector: safeWaitForSelector,
}); });
// Add watermark // Add watermark