Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
36
scripts/adversarial-deep-scan.mjs
Normal file
36
scripts/adversarial-deep-scan.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function walk(dir) {
|
||||
let results = [];
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results = results.concat(walk(fullPath));
|
||||
} else if (/\.(tsx|ts|jsx|js|css|scss)$/.test(entry.name)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const files = walk('src');
|
||||
console.log(`Deep scanning ${files.length} source files in src/...`);
|
||||
|
||||
const rawMatches = [];
|
||||
|
||||
files.forEach(f => {
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
lines.forEach((l, i) => {
|
||||
if (/round|shadow|gradient/i.test(l)) {
|
||||
rawMatches.push({ file: f, line: i + 1, content: l.trim() });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`Total occurrences found: ${rawMatches.length}`);
|
||||
rawMatches.forEach((m, idx) => {
|
||||
console.log(`${idx + 1}. ${m.file}:${m.line} -> ${m.content}`);
|
||||
});
|
||||
197
scripts/adversarial-scan.mjs
Normal file
197
scripts/adversarial-scan.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function walk(dir) {
|
||||
let results = [];
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results = results.concat(walk(fullPath));
|
||||
} else if (/\.(tsx|ts|jsx|js|css|scss)$/.test(entry.name)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const files = walk('src');
|
||||
console.log(`Found ${files.length} source files under src/ to inspect.`);
|
||||
|
||||
const findings = [];
|
||||
|
||||
// Patterns:
|
||||
// 1. Any shadow class except shadow-none, shadow-[none], shadow-[0px]
|
||||
const shadowPattern = /\bshadow(-[a-zA-Z0-9_/\[\]#.-]+)?\b/g;
|
||||
// 2. Any rounded class except rounded-none, rounded-[0px], rounded-0, rounded-[0]
|
||||
const roundedPattern = /\brounded(-[a-zA-Z0-9_/\[\]#.-]+)?\b/g;
|
||||
// 3. bg-gradient-*
|
||||
const bgGradientPattern = /\bbg-gradient(-[a-zA-Z0-9_-]+)?\b/g;
|
||||
// 4. from-* gradient stop
|
||||
const fromPattern = /\bfrom-[a-zA-Z0-9_/\[\]#.-]+\b/g;
|
||||
// 5. via-* gradient stop
|
||||
const viaPattern = /\bvia-[a-zA-Z0-9_/\[\]#.-]+\b/g;
|
||||
// 6. to-* gradient stop (e.g., to-black, to-white, to-slate-900, to-[#...])
|
||||
const toPattern = /\bto-(black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|transparent|current|\[#[a-fA-F0-9]+\])(-[0-9]+)?(\/[0-9]+)?\b/g;
|
||||
// 7. CSS box-shadow
|
||||
const cssBoxShadowPattern = /box-shadow\s*:\s*([^;]+)/gi;
|
||||
// 8. CSS border-radius
|
||||
const cssBorderRadiusPattern = /border-radius\s*:\s*([^;]+)/gi;
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const lineNum = index + 1;
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check CSS properties
|
||||
let match;
|
||||
while ((match = cssBoxShadowPattern.exec(line)) !== null) {
|
||||
const val = match[1].trim();
|
||||
if (val !== 'none' && val !== '0' && val !== '0px') {
|
||||
findings.push({
|
||||
type: 'CSS_BOX_SHADOW',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: match[0],
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
while ((match = cssBorderRadiusPattern.exec(line)) !== null) {
|
||||
const val = match[1].trim();
|
||||
if (val !== '0' && val !== '0px' && val !== '0 0 0 0') {
|
||||
findings.push({
|
||||
type: 'CSS_BORDER_RADIUS',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: match[0],
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check Tailwind / Class matches
|
||||
// Look for string literals or className definitions
|
||||
// To be thorough, check any occurrence in the file
|
||||
let rMatch;
|
||||
while ((rMatch = roundedPattern.exec(line)) !== null) {
|
||||
const cls = rMatch[0];
|
||||
if (
|
||||
cls !== 'rounded-none' &&
|
||||
cls !== 'rounded-[0px]' &&
|
||||
cls !== 'rounded-0' &&
|
||||
cls !== 'rounded-[0]'
|
||||
) {
|
||||
// Exclude JS identifier words like roundedTotal, Math.round, etc. if not a class token
|
||||
// A class token is typically in quotes, backticks, or preceded/followed by whitespace/quotes
|
||||
const before = line[rMatch.index - 1] || ' ';
|
||||
const after = line[rMatch.index + cls.length] || ' ';
|
||||
if (
|
||||
/['"`\s=:({[,>]/.test(before) &&
|
||||
/['"`\s=:)}],<]/.test(after)
|
||||
) {
|
||||
findings.push({
|
||||
type: 'FORBIDDEN_ROUNDED_CLASS',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: cls,
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sMatch;
|
||||
while ((sMatch = shadowPattern.exec(line)) !== null) {
|
||||
const cls = sMatch[0];
|
||||
if (
|
||||
cls !== 'shadow-none' &&
|
||||
cls !== 'shadow-[none]' &&
|
||||
cls !== 'shadow-[0px]'
|
||||
) {
|
||||
const before = line[sMatch.index - 1] || ' ';
|
||||
const after = line[sMatch.index + cls.length] || ' ';
|
||||
if (
|
||||
/['"`\s=:({[,>]/.test(before) &&
|
||||
/['"`\s=:)}],<]/.test(after)
|
||||
) {
|
||||
findings.push({
|
||||
type: 'FORBIDDEN_SHADOW_CLASS',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: cls,
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gMatch;
|
||||
while ((gMatch = bgGradientPattern.exec(line)) !== null) {
|
||||
findings.push({
|
||||
type: 'FORBIDDEN_BG_GRADIENT_CLASS',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: gMatch[0],
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
|
||||
let fMatch;
|
||||
while ((fMatch = fromPattern.exec(line)) !== null) {
|
||||
// Exclude JS imports: import { ... } from '...'
|
||||
if (!/\bimport\b|\bexport\b/.test(line)) {
|
||||
findings.push({
|
||||
type: 'FORBIDDEN_FROM_GRADIENT_CLASS',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: fMatch[0],
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let vMatch;
|
||||
while ((vMatch = viaPattern.exec(line)) !== null) {
|
||||
findings.push({
|
||||
type: 'FORBIDDEN_VIA_GRADIENT_CLASS',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: vMatch[0],
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
|
||||
let tMatch;
|
||||
while ((tMatch = toPattern.exec(line)) !== null) {
|
||||
// Check if it's inside className or class attribute or tailwind string
|
||||
findings.push({
|
||||
type: 'FORBIDDEN_TO_GRADIENT_CLASS',
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
matched: tMatch[0],
|
||||
raw: trimmed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('=== ADVERSARIAL SCAN REPORT ===');
|
||||
console.log(`Scanned files: ${files.length}`);
|
||||
console.log(`Total violations detected: ${findings.length}`);
|
||||
if (findings.length > 0) {
|
||||
console.log('\n--- VIOLATIONS LIST ---');
|
||||
findings.forEach((f, idx) => {
|
||||
console.log(`${idx + 1}. [${f.type}] ${f.file}:${f.line}`);
|
||||
console.log(` Token: "${f.matched}"`);
|
||||
console.log(` Line: ${f.raw}`);
|
||||
});
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('CLEAN: No forbidden rounded, shadow, gradient, or box-shadow tokens found in src/');
|
||||
process.exit(0);
|
||||
}
|
||||
120
scripts/analyze_aso_keywords.mjs
Normal file
120
scripts/analyze_aso_keywords.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
// Script to query Apple iTunes Search API for ASO & Competition Analysis
|
||||
import https from 'https';
|
||||
|
||||
const KEYWORDS_DE = [
|
||||
'receipt scanner',
|
||||
'beleg scanner',
|
||||
'belege digitalisieren',
|
||||
'rechnung scanner',
|
||||
'kassenbon scanner',
|
||||
'quittung scanner',
|
||||
'spesen app',
|
||||
'buchhaltung scanner',
|
||||
'datev scanner',
|
||||
'receipt to excel',
|
||||
'expense tracker',
|
||||
'ausgaben tracker',
|
||||
'belegmanager',
|
||||
'rechnungsprogramm',
|
||||
'fahrtenbuch und belege',
|
||||
'steuer belege',
|
||||
'ocr scanner excel'
|
||||
];
|
||||
|
||||
const KEYWORDS_US = [
|
||||
'receipt scanner',
|
||||
'receipt to excel',
|
||||
'receipt scanner to excel',
|
||||
'expense tracker',
|
||||
'receipt keeper',
|
||||
'invoice scanner',
|
||||
'receipts and expenses',
|
||||
'ocr receipt scanner',
|
||||
'bookkeeping scanner',
|
||||
'mileage and receipts',
|
||||
'tax receipt organizer',
|
||||
'smart receipt'
|
||||
];
|
||||
|
||||
function fetchAppleSearch(term, country = 'de', limit = 25) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=${limit}`;
|
||||
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
resolve(json);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function analyzeKeywords(keywords, country) {
|
||||
console.log(`\n======================================================`);
|
||||
console.log(`🔍 ANALYSING APPLE APP STORE SEARCH DATA [Country: ${country.toUpperCase()}]`);
|
||||
console.log(`======================================================\n`);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const kw of keywords) {
|
||||
try {
|
||||
const data = await fetchAppleSearch(kw, country, 25);
|
||||
const totalResults = data.resultCount;
|
||||
const apps = data.results || [];
|
||||
|
||||
// Metrics calculation
|
||||
const top5 = apps.slice(0, 5);
|
||||
const top5Names = top5.map(a => a.trackName);
|
||||
const avgRating = top5.reduce((acc, a) => acc + (a.averageUserRating || 0), 0) / (top5.length || 1);
|
||||
const totalTop5Ratings = top5.reduce((acc, a) => acc + (a.userRatingCount || 0), 0);
|
||||
const avgTop5RatingCount = Math.round(totalTop5Ratings / (top5.length || 1));
|
||||
|
||||
// Check title keyword match density in top 10
|
||||
const titleMatches = apps.slice(0, 10).filter(a =>
|
||||
(a.trackName || '').toLowerCase().includes(kw.toLowerCase()) ||
|
||||
(a.description || '').toLowerCase().includes(kw.toLowerCase())
|
||||
).length;
|
||||
|
||||
// Price distribution
|
||||
const freeCount = top5.filter(a => a.price === 0).length;
|
||||
|
||||
results.push({
|
||||
keyword: kw,
|
||||
resultCount: totalResults,
|
||||
top1: apps[0] ? `${apps[0].trackName} (${apps[0].userRatingCount || 0} reviews, ★${apps[0].averageUserRating?.toFixed(1) || '0'})` : 'None',
|
||||
top5AvgRating: avgRating.toFixed(2),
|
||||
avgTop5Reviews: avgTop5RatingCount,
|
||||
titleMatchInTop10: titleMatches,
|
||||
topCompetitors: top5.map(a => ({
|
||||
name: a.trackName,
|
||||
seller: a.sellerName,
|
||||
reviews: a.userRatingCount || 0,
|
||||
rating: a.averageUserRating || 0,
|
||||
price: a.price,
|
||||
genres: a.genres
|
||||
}))
|
||||
});
|
||||
|
||||
// Avoid hitting rate limits
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (err) {
|
||||
console.error(`Error fetching "${kw}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const deResults = await analyzeKeywords(KEYWORDS_DE, 'de');
|
||||
const usResults = await analyzeKeywords(KEYWORDS_US, 'us');
|
||||
|
||||
console.log(JSON.stringify({ de: deResults, us: usResults }, null, 2));
|
||||
}
|
||||
|
||||
run();
|
||||
63
scripts/analyze_zimmerpflanze.mjs
Normal file
63
scripts/analyze_zimmerpflanze.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import https from 'https';
|
||||
|
||||
const keywords = [
|
||||
'zimmerpflanze',
|
||||
'zimmerpflanzen',
|
||||
'zimmerpflanzen pflege',
|
||||
'pflanzen bestimmen',
|
||||
'pflanzen app',
|
||||
'pflanzendoktor',
|
||||
'pflanzen gießen erinnerung',
|
||||
'houseplant',
|
||||
'plant care'
|
||||
];
|
||||
|
||||
const storefronts = [
|
||||
{ code: 'de', name: 'Deutschland' },
|
||||
{ code: 'at', name: 'Österreich' },
|
||||
{ code: 'ch', name: 'Schweiz' }
|
||||
];
|
||||
|
||||
function fetchSearch(term, country) {
|
||||
return new Promise((resolve) => {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=10`;
|
||||
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data).results || []);
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}).on('error', () => resolve([]));
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log("Analysiere 'Zimmerpflanze' und verwandte Keywords im App Store...\n");
|
||||
|
||||
for (const sf of storefronts) {
|
||||
console.log(`========================================`);
|
||||
console.log(`Storefront: ${sf.name} (${sf.code.toUpperCase()})`);
|
||||
console.log(`========================================`);
|
||||
|
||||
for (const kw of keywords) {
|
||||
const apps = await fetchSearch(kw, sf.code);
|
||||
const top5 = apps.slice(0, 5);
|
||||
const avgReviews = top5.length ? Math.round(top5.reduce((s, a) => s + (a.userRatingCount || 0), 0) / top5.length) : 0;
|
||||
const top1 = top5[0] || {};
|
||||
|
||||
console.log(`\nKeyword: "${kw}"`);
|
||||
console.log(`- Apps gefunden: ${apps.length}`);
|
||||
console.log(`- Top 1: ${top1.trackName || 'Keine'} (${top1.userRatingCount || 0} Reviews, ★${top1.averageUserRating?.toFixed(1) || 0})`);
|
||||
console.log(`- Ø Reviews Top 5: ${avgReviews.toLocaleString('de-DE')}`);
|
||||
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
}
|
||||
console.log("\n");
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
114
scripts/apply-db-permissions.mjs
Normal file
114
scripts/apply-db-permissions.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Apply scripts/db-permissions.sql to a PostgreSQL database.
|
||||
*
|
||||
* Connects with the OWNER / migration URL (DATABASE_URL from the environment,
|
||||
* .env.local or .env) because creating roles, setting default privileges and
|
||||
* revoking CREATE from PUBLIC require owner/superuser rights. The runtime role
|
||||
* it provisions (`receipt_app`) is the least-privilege role the application
|
||||
* should use at runtime — see scripts/db-permissions.sql for the full grant
|
||||
* list.
|
||||
*
|
||||
* Safe to re-run: the SQL file is idempotent. If APP_DATABASE_PASSWORD is set
|
||||
* (env or .env.local), it replaces the documented default password in the SQL
|
||||
* and appends an ALTER ROLE so an existing role's password is rotated too.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/apply-db-permissions.mjs
|
||||
* APP_DATABASE_PASSWORD=<secret> node scripts/apply-db-permissions.mjs
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "pg";
|
||||
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const DEFAULT_RUNTIME_PASSWORD = "receipt_app_secure_password";
|
||||
|
||||
/** Minimal .env parser: strips quotes, ignores comments; first key wins. */
|
||||
function parseEnvFile(filePath) {
|
||||
const out = {};
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in out)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SQL single-quoted literal (doubles embedded quotes). */
|
||||
function quoteLiteral(value) {
|
||||
return "'" + String(value).replace(/'/g, "''") + "'";
|
||||
}
|
||||
|
||||
// Env precedence: process.env > .env.local > .env.
|
||||
const fileEnv = { ...parseEnvFile(path.join(ROOT, ".env")), ...parseEnvFile(path.join(ROOT, ".env.local")) };
|
||||
|
||||
const ownerUrl = process.env.DATABASE_URL || fileEnv.DATABASE_URL;
|
||||
if (!ownerUrl) {
|
||||
console.error("FATAL: DATABASE_URL not found. Set it in the environment, .env.local or .env.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runtimePassword = process.env.APP_DATABASE_PASSWORD || fileEnv.APP_DATABASE_PASSWORD || null;
|
||||
|
||||
let sql = readFileSync(path.join(ROOT, "scripts", "db-permissions.sql"), "utf8");
|
||||
|
||||
// Optional password override: swap the documented default for the provided one
|
||||
// and ensure the role's password matches APP_DATABASE_PASSWORD (ALTER ROLE is
|
||||
// always appended when the variable is set, so re-runs converge — including
|
||||
// rotating a previously custom password back to the default).
|
||||
let passwordSource = "documented default (receipt_app_secure_password)";
|
||||
if (runtimePassword) {
|
||||
if (runtimePassword === DEFAULT_RUNTIME_PASSWORD) {
|
||||
console.log("APP_DATABASE_PASSWORD equals the documented default - CREATE ROLE token unchanged, password confirmed via ALTER ROLE.");
|
||||
} else {
|
||||
const token = `PASSWORD '${DEFAULT_RUNTIME_PASSWORD}'`;
|
||||
if (!sql.includes(token)) {
|
||||
console.error("FATAL: could not locate the default password token in db-permissions.sql.");
|
||||
process.exit(1);
|
||||
}
|
||||
sql = sql.split(token).join(`PASSWORD ${quoteLiteral(runtimePassword)}`);
|
||||
passwordSource = "APP_DATABASE_PASSWORD (custom)";
|
||||
}
|
||||
sql +=
|
||||
`\n-- Password convergence appended by scripts/apply-db-permissions.mjs\n` +
|
||||
`ALTER ROLE receipt_app WITH PASSWORD ${quoteLiteral(runtimePassword)};\n`;
|
||||
} else {
|
||||
console.log("APP_DATABASE_PASSWORD not set - role password left at the documented default from db-permissions.sql.");
|
||||
}
|
||||
|
||||
const client = new Client({ connectionString: ownerUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
console.log(`Connected as owner (${new URL(ownerUrl).username || "?"}) to execute db-permissions.sql ...`);
|
||||
await client.query(sql); // pg executes the multi-statement SQL in one round trip
|
||||
console.log("Applied scripts/db-permissions.sql successfully.");
|
||||
console.log(" runtime role : receipt_app (LOGIN, NO SUPERUSER, NO CREATEDB, NO CREATEROLE)");
|
||||
console.log(` password : ${passwordSource}`);
|
||||
console.log(" grants : CONNECT, schema USAGE (no CREATE), table SELECT/INSERT/UPDATE/DELETE,");
|
||||
console.log(" sequence USAGE/SELECT, matching DEFAULT PRIVILEGES for future objects,");
|
||||
console.log(" PUBLIC CREATE on schema public revoked.");
|
||||
} catch (err) {
|
||||
console.error("Failed to apply db-permissions.sql:", err.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
30
scripts/check_luna.mjs
Normal file
30
scripts/check_luna.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import fs from "fs";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
async function checkLuna56() {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: { "Authorization": `Bearer ${apiKey}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
const models = data.data || [];
|
||||
|
||||
const matches = models.filter(m =>
|
||||
m.id.toLowerCase().includes("5.6") ||
|
||||
m.id.toLowerCase().includes("luna") ||
|
||||
m.name.toLowerCase().includes("5.6") ||
|
||||
m.name.toLowerCase().includes("luna")
|
||||
);
|
||||
|
||||
console.log("=== MATCHES FOR '5.6' / 'LUNA' ===");
|
||||
matches.forEach(m => {
|
||||
console.log(`- ID: ${m.id}`);
|
||||
console.log(` Name: ${m.name}`);
|
||||
console.log(` Modalities: ${JSON.stringify(m.architecture?.modality || 'unknown')}`);
|
||||
console.log(` Pricing: ${JSON.stringify(m.pricing)}`);
|
||||
});
|
||||
}
|
||||
|
||||
checkLuna56().catch(console.error);
|
||||
8
scripts/cors-register-hook.mjs
Normal file
8
scripts/cors-register-hook.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Registers scripts/cors-resolve-hook.mjs before the main script runs.
|
||||
*
|
||||
* Usage: node --import ./scripts/cors-register-hook.mjs scripts/verify_cors.mjs
|
||||
*/
|
||||
import { register } from "node:module";
|
||||
|
||||
register("./cors-resolve-hook.mjs", import.meta.url);
|
||||
15
scripts/cors-resolve-hook.mjs
Normal file
15
scripts/cors-resolve-hook.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Node.js ESM resolve hook used by the CORS verification harness.
|
||||
*
|
||||
* Next.js ships no package.json "exports" map, so the canonical bare specifier
|
||||
* `next/server` does not resolve under raw Node ESM (the Next.js bundler
|
||||
* resolves it itself). This hook maps it to the actual CJS file so the
|
||||
* middleware module under test (src/lib/http/cors.ts, which imports
|
||||
* `next/server`) can be loaded by plain `node` for verification.
|
||||
*/
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
if (specifier === "next/server") {
|
||||
return nextResolve("next/server.js", context);
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
70
scripts/country_specific_fetch.mjs
Normal file
70
scripts/country_specific_fetch.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import https from 'https';
|
||||
|
||||
const KEYWORDS = [
|
||||
'receipt scanner to excel',
|
||||
'receipt to excel',
|
||||
'receipt scanner',
|
||||
'expense tracker',
|
||||
'tax receipt organizer',
|
||||
'receipt keeper',
|
||||
'invoice scanner',
|
||||
'bookkeeping scanner',
|
||||
'ocr receipt scanner',
|
||||
'extract receipt to csv'
|
||||
];
|
||||
|
||||
const COUNTRIES = [
|
||||
{ code: 'us', name: 'USA' },
|
||||
{ code: 'gb', name: 'UK' },
|
||||
{ code: 'ca', name: 'Canada' },
|
||||
{ code: 'au', name: 'Australia' }
|
||||
];
|
||||
|
||||
function fetchSearch(term, country) {
|
||||
return new Promise((resolve) => {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=5`;
|
||||
const req = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
resolve({ results: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', () => resolve({ results: [] }));
|
||||
req.setTimeout(4000, () => {
|
||||
req.destroy();
|
||||
resolve({ results: [] });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const table = [];
|
||||
|
||||
for (const kw of KEYWORDS) {
|
||||
const row = { keyword: kw, countries: {} };
|
||||
for (const c of COUNTRIES) {
|
||||
const data = await fetchSearch(kw, c.code);
|
||||
const apps = data.results || [];
|
||||
const top1 = apps[0] || {};
|
||||
const avgReviews = apps.length ? Math.round(apps.reduce((s, a) => s + (a.userRatingCount || 0), 0) / apps.length) : 0;
|
||||
|
||||
row.countries[c.name] = {
|
||||
top1Name: top1.trackName ? top1.trackName.slice(0, 25) : 'None',
|
||||
top1Reviews: top1.userRatingCount || 0,
|
||||
avgReviews,
|
||||
resultCount: apps.length
|
||||
};
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
}
|
||||
table.push(row);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(table, null, 2));
|
||||
}
|
||||
|
||||
run();
|
||||
72
scripts/create-admin.ts
Normal file
72
scripts/create-admin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "fs";
|
||||
|
||||
// Load .env.local into process.env (same pattern as other scripts) BEFORE the
|
||||
// db module initialises, so it picks up the real DATABASE_URL (port 5436).
|
||||
const dotenvContent = fs.readFileSync(".env.local", "utf8");
|
||||
for (const l of dotenvContent.split("\n")) {
|
||||
const trimmed = l.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx !== -1) {
|
||||
process.env[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { db } = await import("../src/lib/db");
|
||||
const { users } = await import("../src/lib/schema/db");
|
||||
const { hashPassword } = await import("../src/lib/auth/password");
|
||||
const { canonicaliseEmail, normaliseEmail } = await import("../src/lib/auth/email");
|
||||
const { newId } = await import("../src/lib/auth/tokens");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const rawEmail = process.env.ADMIN_EMAILS?.split(",")[0]?.trim() ?? "";
|
||||
const password = process.env.ADMIN_PASSWORD ?? "fiesta";
|
||||
|
||||
if (!rawEmail) throw new Error("ADMIN_EMAILS not set");
|
||||
|
||||
const email = canonicaliseEmail(rawEmail);
|
||||
const emailKey = normaliseEmail(rawEmail);
|
||||
if (!emailKey) throw new Error(`Invalid admin email: ${rawEmail}`);
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.emailKey, emailKey)).limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
passwordHash,
|
||||
emailVerifiedAt: new Date(),
|
||||
isGuest: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, existing[0].id));
|
||||
console.log(`Updated admin ${email} (${existing[0].id}) — password reset, email verified`);
|
||||
} else {
|
||||
const row = (
|
||||
await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: newId("usr"),
|
||||
email,
|
||||
emailKey,
|
||||
name: "Timo Knuth",
|
||||
passwordHash,
|
||||
emailVerifiedAt: new Date(),
|
||||
isGuest: false,
|
||||
plan: "free",
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
console.log(`Created admin ${email} (${row.id}) — email verified`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
94
scripts/db-permissions.sql
Normal file
94
scripts/db-permissions.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- ============================================================================
|
||||
-- Database least-privilege provisioning — receipt scanner app
|
||||
-- ============================================================================
|
||||
-- Creates the runtime role `receipt_app` and grants it ONLY the privileges the
|
||||
-- application needs at runtime:
|
||||
--
|
||||
-- * CONNECT on the database
|
||||
-- * USAGE on the public schema — explicitly NO CREATE (no DDL of any kind)
|
||||
-- * SELECT / INSERT / UPDATE / DELETE on all tables in public
|
||||
-- * USAGE / SELECT on all sequences in public
|
||||
-- * the same DML grants as DEFAULT PRIVILEGES, so tables/sequences created
|
||||
-- by the owner during later migrations are covered automatically
|
||||
--
|
||||
-- The role is deliberately NOT a superuser and has no CREATEDB / CREATEROLE /
|
||||
-- CREATE-on-schema rights: if the application is compromised, the attacker's
|
||||
-- database blast radius is limited to reading and modifying rows. They cannot
|
||||
-- create/drop tables, alter the schema, or grant themselves more rights.
|
||||
--
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Who must run this? The database owner or a superuser.
|
||||
-- * `receipt_user` (docker-compose's POSTGRES_USER / the owner URL) KEEPS all
|
||||
-- of its current rights — including DDL for migrations and schema init —
|
||||
-- and is the only role this script ever grants privileges to (besides the
|
||||
-- new runtime role). Nothing here weakens the owner.
|
||||
-- * REVOKE CREATE ON SCHEMA public FROM PUBLIC requires the schema owner or
|
||||
-- a superuser. The official postgres image's /docker-entrypoint-initdb.d
|
||||
-- scripts run as POSTGRES_USER, so that path satisfies this.
|
||||
--
|
||||
-- How is it executed?
|
||||
-- * Fresh database / volume: docker-compose mounts this file into
|
||||
-- /docker-entrypoint-initdb.d/10-db-permissions.sql; the postgres image
|
||||
-- runs it once, before the app starts, as POSTGRES_USER. (Init scripts do
|
||||
-- NOT run on an existing volume.)
|
||||
-- * Existing database (e.g. the local dev DB): `node scripts/apply-db-permissions.mjs`
|
||||
--
|
||||
-- Idempotent: safe to run any number of times. CREATE ROLE is guarded by an
|
||||
-- existence check; every GRANT / REVOKE / ALTER DEFAULT PRIVILEGES is a no-op
|
||||
-- when already applied.
|
||||
--
|
||||
-- Runtime role password:
|
||||
-- The default below (receipt_app_secure_password) matches the docker-compose
|
||||
-- default APP_DATABASE_PASSWORD and is for LOCAL DEVELOPMENT only. For
|
||||
-- production, choose a strong password:
|
||||
-- * docker init path: edit the CREATE ROLE statement below before first
|
||||
-- volume creation (this file runs as-is under psql), or
|
||||
-- * apply-script path: set APP_DATABASE_PASSWORD when running
|
||||
-- scripts/apply-db-permissions.mjs — the script substitutes that value
|
||||
-- and rotates the password of an already-existing role (ALTER ROLE).
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. Create the least-privilege runtime role (guarded -> re-runnable).
|
||||
DO $dbpermissions$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'receipt_app') THEN
|
||||
CREATE ROLE receipt_app
|
||||
LOGIN
|
||||
PASSWORD 'receipt_app_secure_password' -- documented default, see header
|
||||
NOSUPERUSER
|
||||
NOCREATEDB
|
||||
NOCREATEROLE;
|
||||
END IF;
|
||||
END
|
||||
$dbpermissions$;
|
||||
|
||||
-- 2. Allow the app to connect. The database name matches POSTGRES_DB /
|
||||
-- the DATABASE_URL database (receipt_scanner); adjust if you use a
|
||||
-- different database name.
|
||||
GRANT CONNECT ON DATABASE receipt_scanner TO receipt_app;
|
||||
|
||||
-- 3. Schema access: strip anything pre-existing, then grant USAGE only.
|
||||
-- NOTE: CREATE is deliberately NOT granted — receipt_app can never create,
|
||||
-- alter or drop schema objects (no DDL).
|
||||
REVOKE ALL ON SCHEMA public FROM receipt_app;
|
||||
GRANT USAGE ON SCHEMA public TO receipt_app;
|
||||
|
||||
-- 4. DML on the tables / sequences that exist right now. (On a fresh database
|
||||
-- this grants nothing yet — step 5 covers the objects the owner creates
|
||||
-- during migrations.)
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO receipt_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO receipt_app;
|
||||
|
||||
-- 5. Future objects: when the owner (receipt_user) creates tables / sequences
|
||||
-- during later migrations, the runtime role gets the same DML automatically.
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO receipt_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO receipt_app;
|
||||
|
||||
-- 6. Harden the schema: take CREATE away from the PUBLIC pseudo-role so no
|
||||
-- other database user can create objects in public either. Must be run by
|
||||
-- the schema owner / a superuser (see header). The owner (receipt_user) is
|
||||
-- unaffected — it keeps full rights through pg_database_owner — so
|
||||
-- migrations and schema init keep working.
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
83
scripts/fast_multi_country.mjs
Normal file
83
scripts/fast_multi_country.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
const EN_KEYWORDS = [
|
||||
'receipt scanner',
|
||||
'receipt to excel',
|
||||
'receipt scanner to excel',
|
||||
'expense tracker',
|
||||
'receipt keeper',
|
||||
'invoice scanner',
|
||||
'tax receipts',
|
||||
'ocr receipt scanner',
|
||||
'smart receipts',
|
||||
'mileage and receipts',
|
||||
'receipt organizer',
|
||||
'bookkeeping scanner',
|
||||
'scan receipts for taxes',
|
||||
'extract receipt to csv',
|
||||
'business expense tracker'
|
||||
];
|
||||
|
||||
const DE_KEYWORDS = [
|
||||
'beleg scanner',
|
||||
'belege digitalisieren',
|
||||
'rechnung scanner',
|
||||
'kassenbon scanner',
|
||||
'quittung scanner',
|
||||
'spesen app',
|
||||
'buchhaltung scanner',
|
||||
'datev scanner',
|
||||
'receipt to excel',
|
||||
'haushaltsbuch',
|
||||
'ausgaben tracker',
|
||||
'steuer belege',
|
||||
'belegmanager'
|
||||
];
|
||||
|
||||
const COUNTRIES = {
|
||||
US: 'us',
|
||||
GB: 'gb',
|
||||
CA: 'ca',
|
||||
AU: 'au',
|
||||
DE: 'de'
|
||||
};
|
||||
|
||||
async function fetchSearch(term, country) {
|
||||
try {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=5`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.results || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const summary = {};
|
||||
|
||||
for (const [cName, cCode] of Object.entries(COUNTRIES)) {
|
||||
summary[cName] = {};
|
||||
const kws = cName === 'DE' ? DE_KEYWORDS : EN_KEYWORDS;
|
||||
|
||||
// run in chunks of 5
|
||||
for (let i = 0; i < kws.length; i += 5) {
|
||||
const chunk = kws.slice(i, i + 5);
|
||||
await Promise.all(chunk.map(async (kw) => {
|
||||
const apps = await fetchSearch(kw, cCode);
|
||||
const top5Reviews = apps.reduce((sum, a) => sum + (a.userRatingCount || 0), 0);
|
||||
const avgReviews = Math.round(top5Reviews / (apps.length || 1));
|
||||
const top1 = apps[0] || {};
|
||||
|
||||
summary[cName][kw] = {
|
||||
count: apps.length,
|
||||
top1: top1.trackName ? `${top1.trackName.slice(0, 30)} (${top1.userRatingCount || 0} rev)` : 'None',
|
||||
avgReviews
|
||||
};
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
run();
|
||||
113
scripts/fetch_live_store_data.mjs
Normal file
113
scripts/fetch_live_store_data.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const EN_KEYWORDS = [
|
||||
'receipt scanner to excel',
|
||||
'receipt to excel',
|
||||
'receipt scanner',
|
||||
'expense tracker',
|
||||
'tax receipt organizer',
|
||||
'receipt keeper',
|
||||
'invoice scanner',
|
||||
'bookkeeping scanner',
|
||||
'ocr receipt scanner',
|
||||
'extract receipt to csv'
|
||||
];
|
||||
|
||||
const DE_KEYWORDS = [
|
||||
'beleg scanner',
|
||||
'belege digitalisieren',
|
||||
'kassenbon scanner',
|
||||
'quittung scanner',
|
||||
'spesen app',
|
||||
'rechnung scanner',
|
||||
'receipt to excel',
|
||||
'datev scanner',
|
||||
'belegmanager',
|
||||
'ausgaben tracker'
|
||||
];
|
||||
|
||||
const STOREFRONTS = [
|
||||
{ id: 'US', country: 'us', name: 'USA', lang: 'en' },
|
||||
{ id: 'GB', country: 'gb', name: 'UK', lang: 'en' },
|
||||
{ id: 'CA', country: 'ca', name: 'Kanada', lang: 'en' },
|
||||
{ id: 'AU', country: 'au', name: 'Australien', lang: 'en' },
|
||||
{ id: 'DE', country: 'de', name: 'Deutschland', lang: 'de' },
|
||||
{ id: 'AT', country: 'at', name: 'Österreich', lang: 'de' },
|
||||
{ id: 'CH', country: 'ch', name: 'Schweiz', lang: 'de' }
|
||||
];
|
||||
|
||||
function fetchStoreData(term, country) {
|
||||
return new Promise((resolve) => {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=10`;
|
||||
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
resolve(json.results || []);
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}).on('error', () => resolve([]));
|
||||
});
|
||||
}
|
||||
|
||||
function calculateDifficulty(apps, term) {
|
||||
if (!apps || apps.length === 0) return { score: 10, tier: 'Sehr gering' };
|
||||
|
||||
const top5 = apps.slice(0, 5);
|
||||
const totalReviews = top5.reduce((sum, a) => sum + (a.userRatingCount || 0), 0);
|
||||
const avgReviews = totalReviews / top5.length;
|
||||
|
||||
const titleMatches = top5.filter(a => (a.trackName || '').toLowerCase().includes(term.toLowerCase())).length;
|
||||
|
||||
// Logarithmic scale for reviews
|
||||
let reviewScore = 0;
|
||||
if (avgReviews > 500000) reviewScore = 95;
|
||||
else if (avgReviews > 100000) reviewScore = 85;
|
||||
else if (avgReviews > 20000) reviewScore = 70;
|
||||
else if (avgReviews > 5000) reviewScore = 55;
|
||||
else if (avgReviews > 1000) reviewScore = 40;
|
||||
else if (avgReviews > 100) reviewScore = 25;
|
||||
else reviewScore = 15;
|
||||
|
||||
const matchScore = titleMatches * 5;
|
||||
const finalScore = Math.min(99, Math.max(10, Math.round((reviewScore * 0.8) + (matchScore * 0.2))));
|
||||
|
||||
return {
|
||||
score: finalScore,
|
||||
avgReviews: Math.round(avgReviews),
|
||||
top1: top5[0] ? `${top5[0].trackName} (${top5[0].userRatingCount || 0} rev, ★${top5[0].averageUserRating?.toFixed(1) || '0'})` : 'Keine',
|
||||
resultCount: apps.length
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log("Starte Live-Abfrage der Apple Storefront API...");
|
||||
const report = {};
|
||||
|
||||
for (const sf of STOREFRONTS) {
|
||||
console.log(`\nPrüfe Storefront: ${sf.name} (${sf.id})...`);
|
||||
report[sf.id] = { name: sf.name, data: [] };
|
||||
const keywords = sf.lang === 'de' ? DE_KEYWORDS : EN_KEYWORDS;
|
||||
|
||||
for (const kw of keywords) {
|
||||
const apps = await fetchStoreData(kw, sf.country);
|
||||
const diff = calculateDifficulty(apps, kw);
|
||||
|
||||
report[sf.id].data.push({
|
||||
keyword: kw,
|
||||
...diff
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync('scripts/live_api_results.json', JSON.stringify(report, null, 2));
|
||||
console.log("\nLive-Ergebnisse erfolgreich in scripts/live_api_results.json gespeichert!");
|
||||
}
|
||||
|
||||
run();
|
||||
65
scripts/find_vision_model.mjs
Normal file
65
scripts/find_vision_model.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from "fs";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
|
||||
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
|
||||
|
||||
const candidateModels = [
|
||||
"google/gemini-2.0-flash-001",
|
||||
"openai/gpt-4o-mini",
|
||||
"qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
"qwen/qwen-2.5-vl-72b-instruct",
|
||||
"meta-llama/llama-3.2-11b-vision-instruct",
|
||||
];
|
||||
|
||||
async function findWorkingVisionModel() {
|
||||
for (const model of candidateModels) {
|
||||
console.log(`\nTesting model: ${model}...`);
|
||||
try {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "http://localhost:3000",
|
||||
"X-Title": "Receipt Scanner",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Welcher Händler steht auf diesem Beleg und was ist die Gesamtsumme? Antworte kurz im JSON Format { merchant: string, total: number }" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: dataUrl
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
console.log(`Status: ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
if (res.ok && data.choices && data.choices[0]) {
|
||||
console.log(`✓ SUCCESS with ${model}!`);
|
||||
console.log("Content:", data.choices[0].message.content);
|
||||
return model;
|
||||
} else {
|
||||
console.log("Error response:", JSON.stringify(data));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Fetch error:", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findWorkingVisionModel().catch(console.error);
|
||||
82
scripts/generate-sample-pdf.ts
Normal file
82
scripts/generate-sample-pdf.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { generateReceiptPdf } from "../src/lib/export/pdfGenerator";
|
||||
import { ProcessedReceipt } from "../src/lib/schema/receipt";
|
||||
|
||||
const sampleReceipt: ProcessedReceipt = {
|
||||
id: "rec-aral-1",
|
||||
imageHash: "hash-aral",
|
||||
originalFileName: "aral-tankstelle.jpg",
|
||||
fileSizeBytes: 204800,
|
||||
createdAt: "2026-08-18T10:00:00.000Z",
|
||||
updatedAt: "2026-08-18T10:00:00.000Z",
|
||||
status: "ready",
|
||||
merchant: {
|
||||
name: "Aral Tankstelle Station",
|
||||
address: "Musterstraße 12, 10115 Berlin",
|
||||
taxId: null,
|
||||
confidence: 0.98,
|
||||
},
|
||||
date: {
|
||||
isoDate: "2026-08-14",
|
||||
time: "14:22",
|
||||
confidence: 0.96,
|
||||
},
|
||||
documentType: "TANKBELEG",
|
||||
receiptNumber: "AR-982341",
|
||||
currency: "EUR",
|
||||
totalAmount: {
|
||||
value: 68.45,
|
||||
confidence: 0.99,
|
||||
},
|
||||
netAmount: 57.52,
|
||||
tipAmount: null,
|
||||
taxBreakdown: [
|
||||
{
|
||||
ratePercent: 19,
|
||||
taxAmount: 10.93,
|
||||
netAmount: 57.52,
|
||||
},
|
||||
],
|
||||
lineItems: [
|
||||
{
|
||||
description: "Diesel",
|
||||
quantity: 27.46,
|
||||
unitPrice: 1.8,
|
||||
taxRate: 19,
|
||||
price: 49.43,
|
||||
},
|
||||
{
|
||||
description: "Shop Artikel",
|
||||
quantity: 1,
|
||||
unitPrice: null,
|
||||
taxRate: 19,
|
||||
price: 11.86,
|
||||
},
|
||||
],
|
||||
suggestedCategory: "Tanken & KFZ",
|
||||
paymentMethod: "EC_KARTE",
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
userConfirmed: false,
|
||||
},
|
||||
} as ProcessedReceipt;
|
||||
|
||||
async function run() {
|
||||
const pdfBytes = await generateReceiptPdf([sampleReceipt], { locale: "de" });
|
||||
const downloadsDir = path.join(os.homedir(), "Downloads");
|
||||
const targetFile = path.join(downloadsDir, "Belege_Export_ScanReceipts.pdf");
|
||||
|
||||
fs.writeFileSync(targetFile, Buffer.from(pdfBytes));
|
||||
console.log("SUCCESS: PDF saved to", targetFile);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("ERROR generating PDF:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
43
scripts/generate_apple_keys.mjs
Normal file
43
scripts/generate_apple_keys.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
|
||||
// Generate EC key pair with curve prime256v1 (P-256) as required by Apple
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
||||
namedCurve: 'prime256v1',
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
});
|
||||
|
||||
console.log("=== NEUER PUBLIC KEY (FÜR APPLE SEARCH ADS) ===");
|
||||
console.log(publicKey);
|
||||
|
||||
// Update .env.local with the new private key
|
||||
const envPath = '.env.local';
|
||||
let envContent = fs.readFileSync(envPath, 'utf8');
|
||||
|
||||
// Replace or set APPLE_SEARCH_ADS_PRIVATE_KEY
|
||||
const cleanPrivateKey = privateKey.replace(/\r\n/g, '\n');
|
||||
const formattedPrivateKey = `APPLE_SEARCH_ADS_PRIVATE_KEY="${cleanPrivateKey.replace(/\n/g, '\\n')}"`;
|
||||
|
||||
if (envContent.includes('APPLE_SEARCH_ADS_PRIVATE_KEY=')) {
|
||||
envContent = envContent.replace(/APPLE_SEARCH_ADS_PRIVATE_KEY=.*/g, formattedPrivateKey);
|
||||
} else {
|
||||
envContent += `\n${formattedPrivateKey}\n`;
|
||||
}
|
||||
|
||||
// Update public key in .env.local too
|
||||
const formattedPublicKey = `APPLE_SEARCH_ADS_PUBLIC_KEY="${publicKey.replace(/\r\n/g, '\n').replace(/\n/g, '\\n')}"`;
|
||||
if (envContent.includes('APPLE_SEARCH_ADS_PUBLIC_KEY=')) {
|
||||
envContent = envContent.replace(/APPLE_SEARCH_ADS_PUBLIC_KEY=.*/g, formattedPublicKey);
|
||||
} else {
|
||||
envContent += `\n${formattedPublicKey}\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
console.log("\n-> Private Key wurde automatisch in .env.local gespeichert!");
|
||||
383
scripts/generate_art_showcase.mjs
Normal file
383
scripts/generate_art_showcase.mjs
Normal file
@@ -0,0 +1,383 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const showcaseDir = path.join(process.cwd(), "public", "showcase");
|
||||
if (!fs.existsSync(showcaseDir)) {
|
||||
fs.mkdirSync(showcaseDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 1. Synthwave / Cyberpunk Sunset (2400 x 1350 - 16:9 2K)
|
||||
const synthwaveSvg = `
|
||||
<svg width="2400" height="1350" viewBox="0 0 2400 1350" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0a051b" />
|
||||
<stop offset="35%" stop-color="#1b0a3a" />
|
||||
<stop offset="65%" stop-color="#4a0e4e" />
|
||||
<stop offset="85%" stop-color="#a01a7d" />
|
||||
<stop offset="100%" stop-color="#ff5e62" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="sunGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffee55" />
|
||||
<stop offset="40%" stop-color="#ff5483" />
|
||||
<stop offset="100%" stop-color="#8000ff" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="gridGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff007f" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="#00f2fe" stop-opacity="0.1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="mountGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#2d0b4d" />
|
||||
<stop offset="100%" stop-color="#0e041d" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="mountGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#551268" />
|
||||
<stop offset="100%" stop-color="#120420" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="neonGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="25" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="softGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="60" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Sky -->
|
||||
<rect width="2400" height="1350" fill="url(#skyGrad)" />
|
||||
|
||||
<!-- Stars -->
|
||||
${Array.from({ length: 120 })
|
||||
.map(() => {
|
||||
const cx = Math.floor(Math.random() * 2400);
|
||||
const cy = Math.floor(Math.random() * 700);
|
||||
const r = (Math.random() * 1.8 + 0.5).toFixed(1);
|
||||
const op = (Math.random() * 0.7 + 0.3).toFixed(2);
|
||||
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#fff" opacity="${op}" />`;
|
||||
})
|
||||
.join("\n ")}
|
||||
|
||||
<!-- Sun Glow & Sun -->
|
||||
<circle cx="1200" cy="720" r="340" fill="url(#sunGrad)" filter="url(#softGlow)" />
|
||||
|
||||
<!-- Sun Horizontal Blinds (Synthwave Bars) -->
|
||||
${[600, 640, 675, 705, 730, 750, 765, 777]
|
||||
.map((y, i) => `<rect x="800" y="${y}" width="800" height="${4 + i * 2.2}" fill="#1b0a3a" />`)
|
||||
.join("\n ")}
|
||||
|
||||
<!-- Distant Mountains Layer 1 -->
|
||||
<polygon points="0,850 280,680 520,790 850,620 1100,740 1450,590 1780,750 2050,650 2400,820 2400,1350 0,1350" fill="url(#mountGrad1)" opacity="0.95"/>
|
||||
|
||||
<!-- Foreground Mountains Layer 2 -->
|
||||
<polygon points="0,920 380,730 750,880 1200,670 1620,870 1950,710 2400,900 2400,1350 0,1350" fill="url(#mountGrad2)" opacity="0.9"/>
|
||||
|
||||
<!-- Horizon Glow -->
|
||||
<line x1="0" y1="850" x2="2400" y2="850" stroke="#00f2fe" stroke-width="4" filter="url(#neonGlow)" />
|
||||
|
||||
<!-- 3D Perspective Grid Plane -->
|
||||
<rect x="0" y="850" width="2400" height="500" fill="#070210" />
|
||||
|
||||
<!-- Horizontal Grid Lines with exponential spacing -->
|
||||
${[860, 875, 895, 920, 955, 1000, 1060, 1135, 1225, 1335]
|
||||
.map((y) => `<line x1="0" y1="${y}" x2="2400" y2="${y}" stroke="url(#gridGrad)" stroke-width="${1 + (y - 850) / 100}" />`)
|
||||
.join("\n ")}
|
||||
|
||||
<!-- Perspective Vanishing Lines radiating from center horizon -->
|
||||
${Array.from({ length: 33 })
|
||||
.map((_, i) => {
|
||||
const bottomX = (i - 16) * 160 + 1200;
|
||||
return `<line x1="1200" y1="850" x2="${bottomX}" y2="1350" stroke="#ff00a0" stroke-width="1.8" opacity="0.75" />`;
|
||||
})
|
||||
.join("\n ")}
|
||||
|
||||
<!-- Title / Logo Typography -->
|
||||
<text x="1200" y="240" font-family="'Inter', 'Montserrat', 'Helvetica', sans-serif" font-size="64" font-weight="900" letter-spacing="16" fill="#ffffff" text-anchor="middle" filter="url(#neonGlow)">NEON HORIZON</text>
|
||||
<text x="1200" y="295" font-family="'Courier New', monospace" font-size="20" letter-spacing="10" fill="#00f2fe" text-anchor="middle" opacity="0.9">PROCEDURAL VECTOR GRAPHICS ENGINE</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// 2. Premium Dark Mode Glassmorphic FinTech Dashboard (2000 x 2000 - 1:1 Square)
|
||||
const glassmorphismSvg = `
|
||||
<svg width="2000" height="2000" viewBox="0 0 2000 2000" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Background Gradient -->
|
||||
<radialGradient id="bgDark" cx="50%" cy="30%" r="90%">
|
||||
<stop offset="0%" stop-color="#14192b" />
|
||||
<stop offset="50%" stop-color="#0b0e17" />
|
||||
<stop offset="100%" stop-color="#05070c" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Orb Gradients -->
|
||||
<radialGradient id="orbPurple" cx="35%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#b55fe6" />
|
||||
<stop offset="60%" stop-color="#6e24db" />
|
||||
<stop offset="100%" stop-color="#2a0873" />
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="orbCyan" cx="30%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#5bf0e4" />
|
||||
<stop offset="50%" stop-color="#0b9df2" />
|
||||
<stop offset="100%" stop-color="#023b82" />
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="cardGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(255, 255, 255, 0.12)" />
|
||||
<stop offset="100%" stop-color="rgba(255, 255, 255, 0.02)" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="accentGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#6366f1" />
|
||||
<stop offset="50%" stop-color="#a855f7" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="blurOrb" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80" />
|
||||
</filter>
|
||||
|
||||
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="30" stdDeviation="40" flood-color="#000" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="2000" height="2000" fill="url(#bgDark)" />
|
||||
|
||||
<!-- Background Ambient Glow Orbs -->
|
||||
<circle cx="450" cy="500" r="380" fill="url(#orbPurple)" filter="url(#blurOrb)" opacity="0.65" />
|
||||
<circle cx="1550" cy="1400" r="440" fill="url(#orbCyan)" filter="url(#blurOrb)" opacity="0.55" />
|
||||
<circle cx="1600" cy="400" r="280" fill="#ec4899" filter="url(#blurOrb)" opacity="0.35" />
|
||||
|
||||
<!-- Subtle Matrix Dot Grid -->
|
||||
${Array.from({ length: 20 })
|
||||
.map((_, row) =>
|
||||
Array.from({ length: 20 })
|
||||
.map((_, col) => `<circle cx="${100 + col * 95}" cy="${100 + row * 95}" r="2" fill="#ffffff" opacity="0.08" />`)
|
||||
.join("")
|
||||
)
|
||||
.join("\n ")}
|
||||
|
||||
<!-- Main Central Glass Card -->
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="300" y="320" width="1400" height="1360" rx="40" fill="url(#cardGrad)" stroke="rgba(255,255,255,0.22)" stroke-width="2" />
|
||||
|
||||
<!-- Top Bar inside Card -->
|
||||
<circle cx="380" cy="400" r="12" fill="#ef4444" opacity="0.8" />
|
||||
<circle cx="420" cy="400" r="12" fill="#f59e0b" opacity="0.8" />
|
||||
<circle cx="460" cy="400" r="12" fill="#10b981" opacity="0.8" />
|
||||
|
||||
<text x="1000" y="408" font-family="'Inter', sans-serif" font-size="22" font-weight="600" fill="#a1a1aa" text-anchor="middle" letter-spacing="2">QUANTUM ANALYTICS ENTERPRISE</text>
|
||||
|
||||
<!-- Header Stats -->
|
||||
<text x="380" y="520" font-family="'Inter', sans-serif" font-size="20" font-weight="500" fill="#94a3b8" letter-spacing="1">TOTAL REVENUE (YTD)</text>
|
||||
<text x="380" y="590" font-family="'Inter', sans-serif" font-size="64" font-weight="800" fill="#ffffff" letter-spacing="-1">$4,892,340.50</text>
|
||||
|
||||
<!-- Growth Badge -->
|
||||
<rect x="920" y="535" width="140" height="44" rx="22" fill="rgba(16, 185, 129, 0.15)" stroke="#10b981" stroke-width="1.5" />
|
||||
<text x="990" y="563" font-family="'Inter', sans-serif" font-size="18" font-weight="700" fill="#10b981" text-anchor="middle">+34.8% ↑</text>
|
||||
|
||||
<!-- Chart Area -->
|
||||
<g transform="translate(380, 680)">
|
||||
<!-- Grid lines -->
|
||||
<line x1="0" y1="0" x2="1240" y2="0" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
|
||||
<line x1="0" y1="120" x2="1240" y2="120" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
|
||||
<line x1="0" y1="240" x2="1240" y2="240" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
|
||||
<line x1="0" y1="360" x2="1240" y2="360" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
|
||||
|
||||
<!-- Area Chart Gradient Fill -->
|
||||
<defs>
|
||||
<linearGradient id="areaGlow" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#8b5cf6" stop-opacity="0.45" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" stop-opacity="0.0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Smooth Spline Path -->
|
||||
<path d="M 0,320 C 150,280 250,180 400,210 C 550,240 700,80 850,110 C 1000,140 1100,30 1240,10 L 1240,400 L 0,400 Z" fill="url(#areaGlow)" />
|
||||
<path d="M 0,320 C 150,280 250,180 400,210 C 550,240 700,80 850,110 C 1000,140 1100,30 1240,10" fill="none" stroke="url(#accentGrad)" stroke-width="6" stroke-linecap="round" />
|
||||
|
||||
<!-- Glowing Data Points -->
|
||||
<circle cx="400" cy="210" r="9" fill="#8b5cf6" stroke="#fff" stroke-width="3" />
|
||||
<circle cx="850" cy="110" r="9" fill="#ec4899" stroke="#fff" stroke-width="3" />
|
||||
<circle cx="1240" cy="10" r="12" fill="#00f2fe" stroke="#fff" stroke-width="4" />
|
||||
</g>
|
||||
|
||||
<!-- Sub Cards (Mini Metrics) -->
|
||||
<!-- Card 1 -->
|
||||
<g transform="translate(380, 1180)">
|
||||
<rect width="380" height="240" rx="24" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.12)" stroke-width="1.5" />
|
||||
<text x="40" y="60" font-family="'Inter', sans-serif" font-size="18" font-weight="600" fill="#94a3b8">Active Workflows</text>
|
||||
<text x="40" y="130" font-family="'Inter', sans-serif" font-size="44" font-weight="800" fill="#ffffff">18,492</text>
|
||||
<text x="40" y="185" font-family="'Inter', sans-serif" font-size="16" font-weight="500" fill="#38bdf8">99.98% uptime</text>
|
||||
</g>
|
||||
|
||||
<!-- Card 2 -->
|
||||
<g transform="translate(810, 1180)">
|
||||
<rect width="380" height="240" rx="24" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.12)" stroke-width="1.5" />
|
||||
<text x="40" y="60" font-family="'Inter', sans-serif" font-size="18" font-weight="600" fill="#94a3b8">AI Inference Latency</text>
|
||||
<text x="40" y="130" font-family="'Inter', sans-serif" font-size="44" font-weight="800" fill="#ffffff">14.2 ms</text>
|
||||
<text x="40" y="185" font-family="'Inter', sans-serif" font-size="16" font-weight="500" fill="#a855f7">Realtime stream</text>
|
||||
</g>
|
||||
|
||||
<!-- Card 3 -->
|
||||
<g transform="translate(1240, 1180)">
|
||||
<rect width="380" height="240" rx="24" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.12)" stroke-width="1.5" />
|
||||
<text x="40" y="60" font-family="'Inter', sans-serif" font-size="18" font-weight="600" fill="#94a3b8">Data Processed</text>
|
||||
<text x="40" y="130" font-family="'Inter', sans-serif" font-size="44" font-weight="800" fill="#ffffff">942.6 TB</text>
|
||||
<text x="40" y="185" font-family="'Inter', sans-serif" font-size="16" font-weight="500" fill="#10b981">+12.4% vs last week</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// 3. Sacred Geometry / Algorithmic Golden Mandala (2400 x 2400 - Ultra HD)
|
||||
function generateMandalaSvg() {
|
||||
const size = 2400;
|
||||
const center = size / 2;
|
||||
const rings = 12;
|
||||
const petals = 24;
|
||||
|
||||
let elements = [];
|
||||
|
||||
for (let r = 1; r <= rings; r++) {
|
||||
const radius = r * 85;
|
||||
const strokeWidth = (r % 3 === 0 ? 3 : 1.2);
|
||||
const opacity = (0.2 + (r / rings) * 0.7).toFixed(2);
|
||||
elements.push(`<circle cx="${center}" cy="${center}" r="${radius}" fill="none" stroke="url(#goldGrad)" stroke-width="${strokeWidth}" opacity="${opacity}" />`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < petals; i++) {
|
||||
const angle = (i * 360) / petals;
|
||||
elements.push(`
|
||||
<g transform="rotate(${angle} ${center} ${center})">
|
||||
<ellipse cx="${center}" cy="${center - 380}" rx="90" ry="340" fill="none" stroke="url(#goldGrad)" stroke-width="1.8" opacity="0.6" />
|
||||
<circle cx="${center}" cy="${center - 650}" r="25" fill="none" stroke="#ffd700" stroke-width="2.5" />
|
||||
<line x1="${center}" y1="${center - 100}" x2="${center}" y2="${center - 950}" stroke="url(#goldGrad)" stroke-width="1" opacity="0.4" stroke-dasharray="5,8" />
|
||||
<polygon points="${center},${center - 850} ${center - 30},${center - 780} ${center + 30},${center - 780}" fill="url(#goldGrad)" opacity="0.75" />
|
||||
</g>
|
||||
`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const angle = (i * 360) / 48;
|
||||
elements.push(`
|
||||
<g transform="rotate(${angle} ${center} ${center})">
|
||||
<circle cx="${center}" cy="${center - 880}" r="6" fill="#fff5cc" opacity="0.8" />
|
||||
</g>
|
||||
`);
|
||||
}
|
||||
|
||||
return `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="darkCosmos" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="0%" stop-color="#120c02" />
|
||||
<stop offset="60%" stop-color="#070501" />
|
||||
<stop offset="100%" stop-color="#000000" />
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe259" />
|
||||
<stop offset="50%" stop-color="#ffa751" />
|
||||
<stop offset="100%" stop-color="#e67e22" />
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="centerGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#ffdd59" stop-opacity="0.8" />
|
||||
<stop offset="40%" stop-color="#ff9f43" stop-opacity="0.3" />
|
||||
<stop offset="100%" stop-color="#000" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
<filter id="goldGlow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="${size}" height="${size}" fill="url(#darkCosmos)" />
|
||||
|
||||
<!-- Center Ambient Sun Glow -->
|
||||
<circle cx="${center}" cy="${center}" r="550" fill="url(#centerGlow)" />
|
||||
|
||||
<!-- Mandala Geometry -->
|
||||
<g filter="url(#goldGlow)">
|
||||
${elements.join("\n")}
|
||||
</g>
|
||||
|
||||
<!-- Center Focal Core -->
|
||||
<circle cx="${center}" cy="${center}" r="60" fill="url(#goldGrad)" />
|
||||
<circle cx="${center}" cy="${center}" r="80" fill="none" stroke="#fff" stroke-width="2" opacity="0.8" />
|
||||
<circle cx="${center}" cy="${center}" r="110" fill="none" stroke="url(#goldGrad)" stroke-width="4" />
|
||||
|
||||
<text x="${center}" y="${size - 120}" font-family="'Cinzel', 'Times New Roman', serif" font-size="36" letter-spacing="18" fill="#ffd700" text-anchor="middle" opacity="0.9">SACRED ALGORITHMIC HARMONY</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
async function runShowcase() {
|
||||
console.log("🎨 Generating High-Resolution Showcase Art in public/showcase/...\n");
|
||||
|
||||
const items = [
|
||||
{
|
||||
filename: "01_synthwave_cyberpunk_sunset.png",
|
||||
svg: synthwaveSvg,
|
||||
type: "png",
|
||||
},
|
||||
{
|
||||
filename: "02_glassmorphic_fintech_dashboard.png",
|
||||
svg: glassmorphismSvg,
|
||||
type: "png",
|
||||
},
|
||||
{
|
||||
filename: "03_sacred_geometry_mandala_2400.png",
|
||||
svg: generateMandalaSvg(),
|
||||
type: "png",
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
const destPath = path.join(showcaseDir, item.filename);
|
||||
const svgBuffer = Buffer.from(item.svg);
|
||||
|
||||
console.log(`Rendering ${item.filename}...`);
|
||||
|
||||
if (item.type === "png") {
|
||||
await sharp(svgBuffer)
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toFile(destPath);
|
||||
} else {
|
||||
await sharp(svgBuffer)
|
||||
.jpeg({ quality: 98, mozjpeg: true })
|
||||
.toFile(destPath);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(destPath);
|
||||
console.log(`✓ Created: ${item.filename} (${(stat.size / 1024).toFixed(1)} KB)`);
|
||||
}
|
||||
|
||||
console.log("\n🚀 All showcase art generated successfully in public/showcase/!");
|
||||
}
|
||||
|
||||
runShowcase().catch((err) => {
|
||||
console.error("Error generating showcase images:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
368
scripts/generate_demo_images.mjs
Normal file
368
scripts/generate_demo_images.mjs
Normal file
@@ -0,0 +1,368 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const demoDir = path.join(process.cwd(), "public", "demo");
|
||||
if (!fs.existsSync(demoDir)) {
|
||||
fs.mkdirSync(demoDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 1. Aral Tankstelle Receipt SVG
|
||||
const aralSvg = `
|
||||
<svg width="600" height="900" xmlns="http://www.w3.org/2000/svg" style="background:#f7f6f2; font-family:'Courier New', monospace;">
|
||||
<!-- Receipt Paper Background with slight grunge -->
|
||||
<rect x="20" y="20" width="560" height="860" fill="#fffdfa" stroke="#d5d0c8" stroke-width="2" rx="4" />
|
||||
|
||||
<text x="300" y="80" font-size="28" font-weight="bold" text-anchor="middle" fill="#111">ARAL TANKSTELLE</text>
|
||||
<text x="300" y="110" font-size="16" text-anchor="middle" fill="#333">Station 10492 München</text>
|
||||
<text x="300" y="130" font-size="14" text-anchor="middle" fill="#555">Hauptstraße 42, 80331 München</text>
|
||||
<text x="300" y="150" font-size="14" text-anchor="middle" fill="#555">USt-IdNr: DE123456789</text>
|
||||
|
||||
<line x1="50" y1="180" x2="550" y2="180" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<text x="50" y="210" font-size="14" fill="#333">Datum: 14.08.2026</text>
|
||||
<text x="400" y="210" font-size="14" fill="#333">Uhrzeit: 08:42</text>
|
||||
<text x="50" y="235" font-size="14" fill="#333">Beleg-Nr: AR-982341</text>
|
||||
<text x="400" y="235" font-size="14" fill="#333">Säule: 04</text>
|
||||
|
||||
<line x1="50" y1="260" x2="550" y2="260" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<!-- Line items header -->
|
||||
<text x="50" y="290" font-size="15" font-weight="bold" fill="#111">ARTIKEL</text>
|
||||
<text x="360" y="290" font-size="15" font-weight="bold" fill="#111">MENGE</text>
|
||||
<text x="480" y="290" font-size="15" font-weight="bold" fill="#111">EUR</text>
|
||||
|
||||
<!-- Items -->
|
||||
<text x="50" y="330" font-size="15" fill="#222">SUPER E10 (1.699 EUR/l)</text>
|
||||
<text x="370" y="330" font-size="15" fill="#222">38.24 l</text>
|
||||
<text x="480" y="330" font-size="15" fill="#222">64.95 B</text>
|
||||
|
||||
<text x="50" y="365" font-size="15" fill="#222">KAFFEE CREMA GROSS</text>
|
||||
<text x="370" y="365" font-size="15" fill="#222">1</text>
|
||||
<text x="480" y="365" font-size="15" fill="#222">3.50 B</text>
|
||||
|
||||
<line x1="50" y1="410" x2="550" y2="410" stroke="#222" stroke-width="2" />
|
||||
|
||||
<!-- Total -->
|
||||
<text x="50" y="455" font-size="24" font-weight="bold" fill="#000">SUMME EUR</text>
|
||||
<text x="430" y="455" font-size="28" font-weight="bold" fill="#000">68,45</text>
|
||||
|
||||
<line x1="50" y1="480" x2="550" y2="480" stroke="#222" stroke-width="2" />
|
||||
|
||||
<!-- Tax breakdown -->
|
||||
<text x="50" y="520" font-size="14" font-weight="bold" fill="#333">MwSt-Satz</text>
|
||||
<text x="220" y="520" font-size="14" font-weight="bold" fill="#333">Netto</text>
|
||||
<text x="380" y="520" font-size="14" font-weight="bold" fill="#333">MwSt</text>
|
||||
<text x="480" y="520" font-size="14" font-weight="bold" fill="#333">Brutto</text>
|
||||
|
||||
<text x="50" y="550" font-size="14" fill="#333">B = 19%</text>
|
||||
<text x="220" y="550" font-size="14" fill="#333">57,52</text>
|
||||
<text x="380" y="550" font-size="14" fill="#333">10,93</text>
|
||||
<text x="480" y="550" font-size="14" fill="#333">68,45</text>
|
||||
|
||||
<!-- Payment -->
|
||||
<text x="50" y="610" font-size="14" fill="#333">ZAHLUNG: EC-Karte Maestro / Girocard</text>
|
||||
<text x="50" y="635" font-size="14" fill="#333">TERMINAL: 89012241 • TRACE: 4491</text>
|
||||
<text x="50" y="660" font-size="14" fill="#333">TSE-Signatur: VALIDATED</text>
|
||||
|
||||
<!-- Fake Barcode -->
|
||||
<rect x="120" y="700" width="360" height="50" fill="#111" />
|
||||
<line x1="130" y1="700" x2="130" y2="750" stroke="#fff" stroke-width="4" />
|
||||
<line x1="145" y1="700" x2="145" y2="750" stroke="#fff" stroke-width="2" />
|
||||
<line x1="160" y1="700" x2="160" y2="750" stroke="#fff" stroke-width="6" />
|
||||
<line x1="180" y1="700" x2="180" y2="750" stroke="#fff" stroke-width="3" />
|
||||
<line x1="200" y1="700" x2="200" y2="750" stroke="#fff" stroke-width="5" />
|
||||
<line x1="220" y1="700" x2="220" y2="750" stroke="#fff" stroke-width="2" />
|
||||
<line x1="240" y1="700" x2="240" y2="750" stroke="#fff" stroke-width="4" />
|
||||
<line x1="260" y1="700" x2="260" y2="750" stroke="#fff" stroke-width="2" />
|
||||
<line x1="285" y1="700" x2="285" y2="750" stroke="#fff" stroke-width="6" />
|
||||
<line x1="310" y1="700" x2="310" y2="750" stroke="#fff" stroke-width="3" />
|
||||
<line x1="330" y1="700" x2="330" y2="750" stroke="#fff" stroke-width="5" />
|
||||
<line x1="350" y1="700" x2="350" y2="750" stroke="#fff" stroke-width="2" />
|
||||
<line x1="370" y1="700" x2="370" y2="750" stroke="#fff" stroke-width="4" />
|
||||
<line x1="390" y1="700" x2="390" y2="750" stroke="#fff" stroke-width="2" />
|
||||
<line x1="410" y1="700" x2="410" y2="750" stroke="#fff" stroke-width="5" />
|
||||
<line x1="430" y1="700" x2="430" y2="750" stroke="#fff" stroke-width="3" />
|
||||
<line x1="450" y1="700" x2="450" y2="750" stroke="#fff" stroke-width="6" />
|
||||
|
||||
<text x="300" y="800" font-size="14" text-anchor="middle" fill="#555">Vielen Dank für Ihren Besuch!</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// 2. Trattoria Restaurant Bewirtungsbeleg SVG
|
||||
const restaurantSvg = `
|
||||
<svg width="600" height="980" xmlns="http://www.w3.org/2000/svg" style="background:#f5f3ee; font-family:'Courier New', monospace;">
|
||||
<rect x="20" y="20" width="560" height="940" fill="#fffefb" stroke="#ded8cf" stroke-width="2" rx="4" />
|
||||
|
||||
<text x="300" y="80" font-size="26" font-weight="bold" text-anchor="middle" fill="#111">TRATTORIA BELLA VISTA</text>
|
||||
<text x="300" y="110" font-size="15" text-anchor="middle" fill="#333">Italienische Spezialitäten</text>
|
||||
<text x="300" y="130" font-size="14" text-anchor="middle" fill="#555">Marktplatz 12, 10115 Berlin</text>
|
||||
<text x="300" y="150" font-size="14" text-anchor="middle" fill="#555">St.-Nr: 34/815/09123 • USt-Id: DE987654321</text>
|
||||
|
||||
<line x1="50" y1="180" x2="550" y2="180" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<text x="50" y="210" font-size="14" fill="#333">Tisch: 14</text>
|
||||
<text x="250" y="210" font-size="14" fill="#333">Bedienung: Marco</text>
|
||||
<text x="420" y="210" font-size="14" fill="#333">Pax: 2</text>
|
||||
|
||||
<text x="50" y="235" font-size="14" fill="#333">Datum: 14.08.2026</text>
|
||||
<text x="250" y="235" font-size="14" fill="#333">Zeit: 20:15</text>
|
||||
<text x="420" y="235" font-size="14" fill="#333">Rechn-Nr: TR-44201</text>
|
||||
|
||||
<line x1="50" y1="260" x2="550" y2="260" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<!-- Header -->
|
||||
<text x="50" y="295" font-size="15" font-weight="bold" fill="#111">POS</text>
|
||||
<text x="110" y="295" font-size="15" font-weight="bold" fill="#111">BESCHREIBUNG</text>
|
||||
<text x="380" y="295" font-size="15" font-weight="bold" fill="#111">STK</text>
|
||||
<text x="480" y="295" font-size="15" font-weight="bold" fill="#111">GESAMT</text>
|
||||
|
||||
<!-- Items -->
|
||||
<text x="50" y="335" font-size="14" fill="#222">1</text>
|
||||
<text x="110" y="335" font-size="14" fill="#222">Pasta Tartufo</text>
|
||||
<text x="380" y="335" font-size="14" fill="#222">2</text>
|
||||
<text x="480" y="335" font-size="14" fill="#222">44.00 A</text>
|
||||
|
||||
<text x="50" y="370" font-size="14" fill="#222">2</text>
|
||||
<text x="110" y="370" font-size="14" fill="#222">S.Pellegrino 0.75l</text>
|
||||
<text x="380" y="370" font-size="14" fill="#222">1</text>
|
||||
<text x="480" y="370" font-size="14" fill="#222">7.50 A</text>
|
||||
|
||||
<text x="50" y="405" font-size="14" fill="#222">3</text>
|
||||
<text x="110" y="405" font-size="14" fill="#222">Tiramisu Hausgemacht</text>
|
||||
<text x="380" y="405" font-size="14" fill="#222">2</text>
|
||||
<text x="480" y="405" font-size="14" fill="#222">16.00 B</text>
|
||||
|
||||
<text x="50" y="440" font-size="14" fill="#222">4</text>
|
||||
<text x="110" y="440" font-size="14" fill="#222">Espresso Doppio</text>
|
||||
<text x="380" y="440" font-size="14" fill="#222">1</text>
|
||||
<text x="480" y="440" font-size="14" fill="#222">4.50 A</text>
|
||||
|
||||
<text x="50" y="475" font-size="14" fill="#222">5</text>
|
||||
<text x="110" y="475" font-size="14" fill="#222">Trinkgeld / Tip freiwillig</text>
|
||||
<text x="380" y="475" font-size="14" fill="#222">1</text>
|
||||
<text x="480" y="475" font-size="14" fill="#222">12.50 -</text>
|
||||
|
||||
<line x1="50" y1="515" x2="550" y2="515" stroke="#222" stroke-width="2" />
|
||||
|
||||
<!-- Total -->
|
||||
<text x="50" y="560" font-size="24" font-weight="bold" fill="#000">GESAMTBETRAG</text>
|
||||
<text x="430" y="560" font-size="28" font-weight="bold" fill="#000">84,50 €</text>
|
||||
|
||||
<line x1="50" y1="585" x2="550" y2="585" stroke="#222" stroke-width="2" />
|
||||
|
||||
<!-- Steuern -->
|
||||
<text x="50" y="625" font-size="13" font-weight="bold" fill="#444">MwSt-Satz</text>
|
||||
<text x="210" y="625" font-size="13" font-weight="bold" fill="#444">Netto</text>
|
||||
<text x="360" y="625" font-size="13" font-weight="bold" fill="#444">MwSt</text>
|
||||
<text x="470" y="625" font-size="13" font-weight="bold" fill="#444">Brutto</text>
|
||||
|
||||
<text x="50" y="650" font-size="13" fill="#444">A = 19% (Getr./Speis.)</text>
|
||||
<text x="210" y="650" font-size="13" fill="#444">47,06</text>
|
||||
<text x="360" y="650" font-size="13" fill="#444">8,94</text>
|
||||
<text x="470" y="650" font-size="13" fill="#444">56,00</text>
|
||||
|
||||
<text x="50" y="675" font-size="13" fill="#444">B = 7% (Dessert to go)</text>
|
||||
<text x="210" y="675" font-size="13" fill="#444">14,95</text>
|
||||
<text x="360" y="675" font-size="13" fill="#444">1,05</text>
|
||||
<text x="470" y="675" font-size="13" fill="#444">16,00</text>
|
||||
|
||||
<!-- Bewirtungsbeleg Fields -->
|
||||
<rect x="45" y="720" width="510" height="150" fill="#faf8f5" stroke="#bbb" stroke-width="1" />
|
||||
<text x="60" y="745" font-size="12" font-weight="bold" fill="#333">Angaben zur geschäftlichen Bewirtung (§ 4 Abs. 5 EStG):</text>
|
||||
<text x="60" y="775" font-size="12" fill="#666">Anlass der Bewirtung: ................................................</text>
|
||||
<text x="60" y="805" font-size="12" fill="#666">Bewirtete Personen: ................................................</text>
|
||||
<text x="60" y="835" font-size="12" fill="#666">Ort, Datum: Berlin, 14.08.2026 Unterschrift: .......................</text>
|
||||
|
||||
<text x="300" y="910" font-size="14" text-anchor="middle" fill="#555">Arrivederci & Grazie Mille!</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// 3. MediaMarkt Saturn IT Receipt SVG
|
||||
const mediamarktSvg = `
|
||||
<svg width="600" height="920" xmlns="http://www.w3.org/2000/svg" style="background:#f4f5f7; font-family:'Courier New', monospace;">
|
||||
<rect x="20" y="20" width="560" height="880" fill="#ffffff" stroke="#cbd5e1" stroke-width="2" rx="4" />
|
||||
|
||||
<text x="300" y="75" font-size="28" font-weight="bold" text-anchor="middle" fill="#df0000">MediaMarkt</text>
|
||||
<text x="300" y="105" font-size="15" text-anchor="middle" fill="#333">MediaMarkt Saturn Holding GmbH</text>
|
||||
<text x="300" y="125" font-size="14" text-anchor="middle" fill="#555">Alexanderplatz 3, 10178 Berlin</text>
|
||||
<text x="300" y="145" font-size="14" text-anchor="middle" fill="#555">USt-IdNr: DE119876543 • Filiale 204</text>
|
||||
|
||||
<line x1="50" y1="175" x2="550" y2="175" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<text x="50" y="205" font-size="14" fill="#333">Datum: 14.08.2026</text>
|
||||
<text x="400" y="205" font-size="14" fill="#333">Zeit: 14:30:12</text>
|
||||
<text x="50" y="230" font-size="14" fill="#333">Kassenbon-Nr: MM-2026-9811</text>
|
||||
<text x="400" y="230" font-size="14" fill="#333">Kasse: 02</text>
|
||||
|
||||
<line x1="50" y1="255" x2="550" y2="255" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<text x="50" y="290" font-size="15" font-weight="bold" fill="#0f172a">ARTIKEL</text>
|
||||
<text x="380" y="290" font-size="15" font-weight="bold" fill="#0f172a">MENGE</text>
|
||||
<text x="480" y="290" font-size="15" font-weight="bold" fill="#0f172a">PREIS</text>
|
||||
|
||||
<!-- Items -->
|
||||
<text x="50" y="330" font-size="14" fill="#1e293b">USB-C DOCK 100W PRO</text>
|
||||
<text x="50" y="350" font-size="12" fill="#64748b">Art-Nr: 48920194 • 19% MwSt</text>
|
||||
<text x="390" y="330" font-size="14" fill="#1e293b">1</text>
|
||||
<text x="480" y="330" font-size="14" fill="#1e293b">89.99</text>
|
||||
|
||||
<text x="50" y="390" font-size="14" fill="#1e293b">LOGITECH MX MASTER 3S</text>
|
||||
<text x="50" y="410" font-size="12" fill="#64748b">Art-Nr: 29014811 • 19% MwSt</text>
|
||||
<text x="390" y="390" font-size="14" fill="#1e293b">1</text>
|
||||
<text x="480" y="390" font-size="14" fill="#1e293b">40.00</text>
|
||||
|
||||
<line x1="50" y1="455" x2="550" y2="455" stroke="#0f172a" stroke-width="2" />
|
||||
|
||||
<!-- Total -->
|
||||
<text x="50" y="500" font-size="24" font-weight="bold" fill="#0f172a">GESAMTBETRAG</text>
|
||||
<text x="410" y="500" font-size="28" font-weight="bold" fill="#0f172a">129,99 €</text>
|
||||
|
||||
<line x1="50" y1="525" x2="550" y2="525" stroke="#0f172a" stroke-width="2" />
|
||||
|
||||
<!-- Tax breakdown -->
|
||||
<text x="50" y="565" font-size="14" font-weight="bold" fill="#334155">Steuersatz</text>
|
||||
<text x="220" y="565" font-size="14" font-weight="bold" fill="#334155">Netto</text>
|
||||
<text x="370" y="565" font-size="14" font-weight="bold" fill="#334155">Steuer</text>
|
||||
<text x="470" y="565" font-size="14" font-weight="bold" fill="#334155">Brutto</text>
|
||||
|
||||
<text x="50" y="595" font-size="14" fill="#334155">19%</text>
|
||||
<text x="220" y="595" font-size="14" fill="#334155">109,24 €</text>
|
||||
<text x="370" y="595" font-size="14" fill="#334155">20,75 €</text>
|
||||
<text x="470" y="595" font-size="14" fill="#334155">129,99 €</text>
|
||||
|
||||
<!-- Payment -->
|
||||
<text x="50" y="655" font-size="14" fill="#334155">Zahlart: Apple Pay / VISA (**** 4819)</text>
|
||||
<text x="50" y="680" font-size="14" fill="#334155">Autorisierung: 902184 • TSE: OK</text>
|
||||
|
||||
<!-- Barcode -->
|
||||
<rect x="120" y="730" width="360" height="50" fill="#0f172a" />
|
||||
<line x1="140" y1="730" x2="140" y2="780" stroke="#fff" stroke-width="4" />
|
||||
<line x1="165" y1="730" x2="165" y2="780" stroke="#fff" stroke-width="3" />
|
||||
<line x1="190" y1="730" x2="190" y2="780" stroke="#fff" stroke-width="6" />
|
||||
<line x1="220" y1="730" x2="220" y2="780" stroke="#fff" stroke-width="2" />
|
||||
<line x1="250" y1="730" x2="250" y2="780" stroke="#fff" stroke-width="5" />
|
||||
<line x1="280" y1="730" x2="280" y2="780" stroke="#fff" stroke-width="3" />
|
||||
<line x1="310" y1="730" x2="310" y2="780" stroke="#fff" stroke-width="6" />
|
||||
<line x1="340" y1="730" x2="340" y2="780" stroke="#fff" stroke-width="2" />
|
||||
<line x1="370" y1="730" x2="370" y2="780" stroke="#fff" stroke-width="5" />
|
||||
<line x1="400" y1="730" x2="400" y2="780" stroke="#fff" stroke-width="4" />
|
||||
<line x1="430" y1="730" x2="430" y2="780" stroke="#fff" stroke-width="2" />
|
||||
|
||||
<text x="300" y="830" font-size="14" text-anchor="middle" fill="#64748b">Umtausch innerhalb 14 Tagen mit Kassenbon.</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// 4. REWE Supermarkt Kassenbon SVG
|
||||
const reweSvg = `
|
||||
<svg width="600" height="920" xmlns="http://www.w3.org/2000/svg" style="background:#fbfbf9; font-family:'Courier New', monospace;">
|
||||
<rect x="20" y="20" width="560" height="880" fill="#ffffff" stroke="#e2e8f0" stroke-width="2" rx="4" />
|
||||
|
||||
<text x="300" y="75" font-size="32" font-weight="bold" text-anchor="middle" fill="#c00">REWE</text>
|
||||
<text x="300" y="105" font-size="15" text-anchor="middle" fill="#333">REWE Markt GmbH • Filiale 1089</text>
|
||||
<text x="300" y="125" font-size="14" text-anchor="middle" fill="#555">Friedrichstraße 190, 10117 Berlin</text>
|
||||
<text x="300" y="145" font-size="14" text-anchor="middle" fill="#555">USt-IdNr: DE811122334</text>
|
||||
|
||||
<line x1="50" y1="175" x2="550" y2="175" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<text x="50" y="205" font-size="14" fill="#333">Datum: 14.08.2026</text>
|
||||
<text x="400" y="205" font-size="14" fill="#333">Uhrzeit: 17:45</text>
|
||||
<text x="50" y="230" font-size="14" fill="#333">Beleg-Nr: RW-77821</text>
|
||||
<text x="400" y="230" font-size="14" fill="#333">Bon-Kasse: 03</text>
|
||||
|
||||
<line x1="50" y1="255" x2="550" y2="255" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
|
||||
|
||||
<text x="50" y="290" font-size="15" font-weight="bold" fill="#000">ARTIKEL</text>
|
||||
<text x="380" y="290" font-size="15" font-weight="bold" fill="#000">MENGE</text>
|
||||
<text x="480" y="290" font-size="15" font-weight="bold" fill="#000">EUR</text>
|
||||
|
||||
<!-- Items -->
|
||||
<text x="50" y="330" font-size="14" fill="#111">REWE Bio Vollmilch 3.8%</text>
|
||||
<text x="380" y="330" font-size="14" fill="#111">2</text>
|
||||
<text x="480" y="330" font-size="14" fill="#111">3.18 A</text>
|
||||
|
||||
<text x="50" y="365" font-size="14" fill="#111">Bio Bananen (0.85 kg)</text>
|
||||
<text x="380" y="365" font-size="14" fill="#111">1</text>
|
||||
<text x="480" y="365" font-size="14" fill="#111">1.89 A</text>
|
||||
|
||||
<text x="50" y="400" font-size="14" fill="#111">Bio Espresso Bohnen 1kg</text>
|
||||
<text x="380" y="400" font-size="14" fill="#111">1</text>
|
||||
<text x="480" y="400" font-size="14" fill="#111">14.99 A</text>
|
||||
|
||||
<text x="50" y="435" font-size="14" fill="#111">Küchenrolle 4er Pack</text>
|
||||
<text x="380" y="435" font-size="14" fill="#111">1</text>
|
||||
<text x="480" y="435" font-size="14" fill="#111">4.74 B</text>
|
||||
|
||||
<line x1="50" y1="475" x2="550" y2="475" stroke="#000" stroke-width="2" />
|
||||
|
||||
<!-- Total -->
|
||||
<text x="50" y="520" font-size="24" font-weight="bold" fill="#000">SUMME EUR</text>
|
||||
<text x="420" y="520" font-size="28" font-weight="bold" fill="#000">24,80</text>
|
||||
|
||||
<line x1="50" y1="545" x2="550" y2="545" stroke="#000" stroke-width="2" />
|
||||
|
||||
<text x="50" y="585" font-size="13" font-weight="bold" fill="#333">MwSt-Satz</text>
|
||||
<text x="220" y="585" font-size="13" font-weight="bold" fill="#333">Netto</text>
|
||||
<text x="370" y="585" font-size="13" font-weight="bold" fill="#333">MwSt</text>
|
||||
<text x="480" y="585" font-size="13" font-weight="bold" fill="#333">Brutto</text>
|
||||
|
||||
<text x="50" y="615" font-size="13" fill="#333">A = 7% (Lebensmittel)</text>
|
||||
<text x="220" y="615" font-size="13" fill="#333">18,75</text>
|
||||
<text x="370" y="615" font-size="13" fill="#333">1,31</text>
|
||||
<text x="480" y="615" font-size="13" fill="#333">20,06</text>
|
||||
|
||||
<text x="50" y="640" font-size="13" fill="#333">B = 19% (Drogerie)</text>
|
||||
<text x="220" y="640" font-size="13" fill="#333">3,98</text>
|
||||
<text x="370" y="640" font-size="13" fill="#333">0,76</text>
|
||||
<text x="480" y="640" font-size="13" fill="#333">4,74</text>
|
||||
|
||||
<!-- Payment -->
|
||||
<text x="50" y="700" font-size="14" fill="#333">Gegeben: Bar 50,00 EUR • Rückgeld: 25,20 EUR</text>
|
||||
<text x="50" y="725" font-size="14" fill="#333">TSE-Signatur: VALIDATED</text>
|
||||
|
||||
<!-- Barcode -->
|
||||
<rect x="120" y="760" width="360" height="45" fill="#111" />
|
||||
<line x1="140" y1="760" x2="140" y2="805" stroke="#fff" stroke-width="4" />
|
||||
<line x1="170" y1="760" x2="170" y2="805" stroke="#fff" stroke-width="2" />
|
||||
<line x1="200" y1="760" x2="200" y2="805" stroke="#fff" stroke-width="5" />
|
||||
<line x1="230" y1="760" x2="230" y2="805" stroke="#fff" stroke-width="3" />
|
||||
<line x1="260" y1="760" x2="260" y2="805" stroke="#fff" stroke-width="6" />
|
||||
<line x1="290" y1="760" x2="290" y2="805" stroke="#fff" stroke-width="2" />
|
||||
<line x1="320" y1="760" x2="320" y2="805" stroke="#fff" stroke-width="5" />
|
||||
<line x1="350" y1="760" x2="350" y2="805" stroke="#fff" stroke-width="3" />
|
||||
<line x1="380" y1="760" x2="380" y2="805" stroke="#fff" stroke-width="6" />
|
||||
<line x1="410" y1="760" x2="410" y2="805" stroke="#fff" stroke-width="2" />
|
||||
<line x1="440" y1="760" x2="440" y2="805" stroke="#fff" stroke-width="4" />
|
||||
|
||||
<text x="300" y="850" font-size="14" text-anchor="middle" fill="#555">Vielen Dank für Ihren Einkauf bei REWE!</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
async function generateDemoImages() {
|
||||
console.log("Generating Demo Receipt Images in public/demo/...");
|
||||
|
||||
const images = [
|
||||
{ filename: "01_aral_tankbeleg_muenchen.jpg", svg: aralSvg },
|
||||
{ filename: "02_trattoria_bewirtungsbeleg_berlin.jpg", svg: restaurantSvg },
|
||||
{ filename: "03_mediamarkt_it_rechnung.jpg", svg: mediamarktSvg },
|
||||
{ filename: "04_rewe_supermarkt_kassenbon.jpg", svg: reweSvg },
|
||||
];
|
||||
|
||||
for (const img of images) {
|
||||
const destPath = path.join(demoDir, img.filename);
|
||||
const svgBuffer = Buffer.from(img.svg);
|
||||
|
||||
await sharp(svgBuffer)
|
||||
.jpeg({ quality: 90, mozjpeg: true })
|
||||
.toFile(destPath);
|
||||
|
||||
console.log(`✓ Created demo receipt: ${img.filename}`);
|
||||
}
|
||||
|
||||
console.log("All demo receipt images created successfully in public/demo/");
|
||||
}
|
||||
|
||||
generateDemoImages().catch((err) => {
|
||||
console.error("Failed to generate demo images:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
29
scripts/inspect_excel.mjs
Normal file
29
scripts/inspect_excel.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
async function inspect() {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile("C:/Users/timo/Downloads/Belege_Export_2026-08-14.xlsx");
|
||||
|
||||
console.log("=== WORKBOOK SHEETS ===");
|
||||
wb.eachSheet((sheet, id) => {
|
||||
console.log(`Sheet ${id}: "${sheet.name}" (Rows: ${sheet.rowCount}, Cols: ${sheet.columnCount})`);
|
||||
});
|
||||
|
||||
const s1 = wb.getWorksheet("Belegübersicht");
|
||||
if (s1) {
|
||||
console.log("\n=== SHEET 1: Belegübersicht ===");
|
||||
s1.eachRow((row, rowNumber) => {
|
||||
console.log(`Row ${rowNumber}:`, JSON.stringify(row.values));
|
||||
});
|
||||
}
|
||||
|
||||
const s2 = wb.getWorksheet("Einzelpositionen Detail");
|
||||
if (s2) {
|
||||
console.log("\n=== SHEET 2: Einzelpositionen Detail ===");
|
||||
s2.eachRow((row, rowNumber) => {
|
||||
console.log(`Row ${rowNumber}:`, JSON.stringify(row.values));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
inspect().catch(console.error);
|
||||
527
scripts/live_api_results.json
Normal file
527
scripts/live_api_results.json
Normal file
@@ -0,0 +1,527 @@
|
||||
{
|
||||
"US": {
|
||||
"name": "USA",
|
||||
"data": [
|
||||
{
|
||||
"keyword": "receipt scanner to excel",
|
||||
"score": 68,
|
||||
"avgReviews": 394356,
|
||||
"top1": "Photo to PDF Convert Scanner + (26467 rev, ★4.6)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "receipt to excel",
|
||||
"score": 68,
|
||||
"avgReviews": 121747,
|
||||
"top1": "Intuit QuickBooks for Business (259333 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt scanner",
|
||||
"score": 77,
|
||||
"avgReviews": 1800587,
|
||||
"top1": "Fetch: Receipts for Gift Cards (7545646 rev, ★4.9)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "expense tracker",
|
||||
"score": 56,
|
||||
"avgReviews": 39492,
|
||||
"top1": "Spending Tracker (19354 rev, ★4.8)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "tax receipt organizer",
|
||||
"score": 56,
|
||||
"avgReviews": 23178,
|
||||
"top1": "Smart Receipts: Expenses & Tax (12768 rev, ★4.8)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "receipt keeper",
|
||||
"score": 44,
|
||||
"avgReviews": 10555,
|
||||
"top1": "SimplyWise: Receipts, Expenses (36875 rev, ★4.9)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "invoice scanner",
|
||||
"score": 76,
|
||||
"avgReviews": 766384,
|
||||
"top1": "Scanner App: Genius Scan (1353025 rev, ★4.9)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "bookkeeping scanner",
|
||||
"score": 44,
|
||||
"avgReviews": 9568,
|
||||
"top1": "Receipt Lens - Expense Tracker (4250 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "ocr receipt scanner",
|
||||
"score": 68,
|
||||
"avgReviews": 325217,
|
||||
"top1": "Receipt Scanner・Track Expenses (3850 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "extract receipt to csv",
|
||||
"score": 56,
|
||||
"avgReviews": 57993,
|
||||
"top1": "Smart Receipts: Expenses & Tax (12768 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
"GB": {
|
||||
"name": "UK",
|
||||
"data": [
|
||||
{
|
||||
"keyword": "receipt scanner to excel",
|
||||
"score": 56,
|
||||
"avgReviews": 25470,
|
||||
"top1": "CamScanner - PDF Scanner App (118125 rev, ★4.8)",
|
||||
"resultCount": 8
|
||||
},
|
||||
{
|
||||
"keyword": "receipt to excel",
|
||||
"score": 56,
|
||||
"avgReviews": 28152,
|
||||
"top1": "Shoppix (17604 rev, ★4.7)",
|
||||
"resultCount": 8
|
||||
},
|
||||
{
|
||||
"keyword": "receipt scanner",
|
||||
"score": 57,
|
||||
"avgReviews": 23797,
|
||||
"top1": "Scanner App: Genius Scan (99819 rev, ★4.8)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "expense tracker",
|
||||
"score": 44,
|
||||
"avgReviews": 9617,
|
||||
"top1": "Spending Tracker (5229 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "tax receipt organizer",
|
||||
"score": 32,
|
||||
"avgReviews": 1265,
|
||||
"top1": "Smart Receipts: Tax, Expenses (1259 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt keeper",
|
||||
"score": 44,
|
||||
"avgReviews": 8806,
|
||||
"top1": "1tap receipts: Track Expenses (4879 rev, ★4.7)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "invoice scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 39909,
|
||||
"top1": "Scanner – Scan PDF & Document (33855 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "bookkeeping scanner",
|
||||
"score": 44,
|
||||
"avgReviews": 7408,
|
||||
"top1": "JotNot Scanner App Pro (1774 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "ocr receipt scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 84959,
|
||||
"top1": "Scanner – Scan PDF & Document (33855 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "extract receipt to csv",
|
||||
"score": 32,
|
||||
"avgReviews": 4779,
|
||||
"top1": "1tap receipts: Track Expenses (4879 rev, ★4.7)",
|
||||
"resultCount": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
"CA": {
|
||||
"name": "Kanada",
|
||||
"data": [
|
||||
{
|
||||
"keyword": "receipt scanner to excel",
|
||||
"score": 56,
|
||||
"avgReviews": 32453,
|
||||
"top1": "CamScanner - PDF Scanner App (149843 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt to excel",
|
||||
"score": 56,
|
||||
"avgReviews": 39481,
|
||||
"top1": "Receipt Hog: Shopping Rewards (28938 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt scanner",
|
||||
"score": 57,
|
||||
"avgReviews": 30270,
|
||||
"top1": "Receipt Scanner: Easy Expense (1235 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "expense tracker",
|
||||
"score": 44,
|
||||
"avgReviews": 5733,
|
||||
"top1": "Money Manager Expense & Budget (5217 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "tax receipt organizer",
|
||||
"score": 32,
|
||||
"avgReviews": 2179,
|
||||
"top1": "Smart Receipts: Expenses & Tax (1427 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt keeper",
|
||||
"score": 32,
|
||||
"avgReviews": 3992,
|
||||
"top1": "Smart Receipts: Expenses & Tax (1427 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "invoice scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 50676,
|
||||
"top1": "Scanner – Scan PDF & Document (34215 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "bookkeeping scanner",
|
||||
"score": 44,
|
||||
"avgReviews": 8397,
|
||||
"top1": "Receipt Lens - Receipt Scanner (455 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "ocr receipt scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 97427,
|
||||
"top1": "Scanner – Scan PDF & Document (34215 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "extract receipt to csv",
|
||||
"score": 44,
|
||||
"avgReviews": 12339,
|
||||
"top1": "Dext: Expense Tracker (12900 rev, ★4.8)",
|
||||
"resultCount": 9
|
||||
}
|
||||
]
|
||||
},
|
||||
"AU": {
|
||||
"name": "Australien",
|
||||
"data": [
|
||||
{
|
||||
"keyword": "receipt scanner to excel",
|
||||
"score": 44,
|
||||
"avgReviews": 17539,
|
||||
"top1": "CamScanner - PDF Scanner App (81525 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt to excel",
|
||||
"score": 56,
|
||||
"avgReviews": 46549,
|
||||
"top1": "Microsoft Excel (132204 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt scanner",
|
||||
"score": 57,
|
||||
"avgReviews": 45599,
|
||||
"top1": "Scanner App: Genius Scan (78375 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "expense tracker",
|
||||
"score": 33,
|
||||
"avgReviews": 1640,
|
||||
"top1": "Buddy: Money & Budget Planner (3519 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "tax receipt organizer",
|
||||
"score": 44,
|
||||
"avgReviews": 5513,
|
||||
"top1": "QuickBooks Self-Employed (3372 rev, ★4.5)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt keeper",
|
||||
"score": 32,
|
||||
"avgReviews": 4088,
|
||||
"top1": "Keep Receipt (248 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "invoice scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 24672,
|
||||
"top1": "Scanner – Scan PDF & Document (17872 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "bookkeeping scanner",
|
||||
"score": 32,
|
||||
"avgReviews": 4241,
|
||||
"top1": "Receipt Lens - Expense Tracker (435 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "ocr receipt scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 58577,
|
||||
"top1": "Scanner – Scan PDF & Document (17872 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "extract receipt to csv",
|
||||
"score": 32,
|
||||
"avgReviews": 4235,
|
||||
"top1": "Crunchr Receipt Scanner (695 rev, ★4.7)",
|
||||
"resultCount": 9
|
||||
}
|
||||
]
|
||||
},
|
||||
"DE": {
|
||||
"name": "Deutschland",
|
||||
"data": [
|
||||
{
|
||||
"keyword": "beleg scanner",
|
||||
"score": 44,
|
||||
"avgReviews": 15304,
|
||||
"top1": "Lexware Scan (14051 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "belege digitalisieren",
|
||||
"score": 12,
|
||||
"avgReviews": 45,
|
||||
"top1": "Belegsammler (0 rev, ★0.0)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "kassenbon scanner",
|
||||
"score": 32,
|
||||
"avgReviews": 1513,
|
||||
"top1": "epap Kassenbon & Haushaltsbuch (3045 rev, ★4.5)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "quittung scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 26086,
|
||||
"top1": "QuittungScanner: Klug Sparen (0 rev, ★0.0)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "spesen app",
|
||||
"score": 32,
|
||||
"avgReviews": 2190,
|
||||
"top1": "Spesen und Reisekosten App (16 rev, ★4.2)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "rechnung scanner",
|
||||
"score": 57,
|
||||
"avgReviews": 26969,
|
||||
"top1": "Rechnung Scanner (0 rev, ★0.0)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "receipt to excel",
|
||||
"score": 44,
|
||||
"avgReviews": 17090,
|
||||
"top1": "CamScanner - PDF Scanner App (85416 rev, ★4.7)",
|
||||
"resultCount": 7
|
||||
},
|
||||
{
|
||||
"keyword": "datev scanner",
|
||||
"score": 56,
|
||||
"avgReviews": 33934,
|
||||
"top1": "DATEV Upload mobil (57685 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "belegmanager",
|
||||
"score": 13,
|
||||
"avgReviews": 0,
|
||||
"top1": "Belegmanager: Belegthek (1 rev, ★5.0)",
|
||||
"resultCount": 4
|
||||
},
|
||||
{
|
||||
"keyword": "ausgaben tracker",
|
||||
"score": 33,
|
||||
"avgReviews": 3841,
|
||||
"top1": "Haushaltsbuch Ausgaben Monee (9844 rev, ★4.9)",
|
||||
"resultCount": 9
|
||||
}
|
||||
]
|
||||
},
|
||||
"AT": {
|
||||
"name": "Österreich",
|
||||
"data": [
|
||||
{
|
||||
"keyword": "beleg scanner",
|
||||
"score": 21,
|
||||
"avgReviews": 558,
|
||||
"top1": "Belegscanner: Foto & Speichern (0 rev, ★0.0)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "belege digitalisieren",
|
||||
"score": 12,
|
||||
"avgReviews": 3,
|
||||
"top1": "Belegsammler (1 rev, ★5.0)",
|
||||
"resultCount": 8
|
||||
},
|
||||
{
|
||||
"keyword": "kassenbon scanner",
|
||||
"score": 13,
|
||||
"avgReviews": 63,
|
||||
"top1": "epap Kassenbon & Haushaltsbuch (105 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "quittung scanner",
|
||||
"score": 32,
|
||||
"avgReviews": 3721,
|
||||
"top1": "Scanner App: Genius Scan (18584 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "spesen app",
|
||||
"score": 12,
|
||||
"avgReviews": 38,
|
||||
"top1": "Spesen und Reisekosten App (1 rev, ★3.0)",
|
||||
"resultCount": 7
|
||||
},
|
||||
{
|
||||
"keyword": "rechnung scanner",
|
||||
"score": 21,
|
||||
"avgReviews": 887,
|
||||
"top1": "Rechnungsscanner (1 rev, ★4.0)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "receipt to excel",
|
||||
"score": 32,
|
||||
"avgReviews": 1425,
|
||||
"top1": "CamScanner - PDF Scanner App (7116 rev, ★4.7)",
|
||||
"resultCount": 7
|
||||
},
|
||||
{
|
||||
"keyword": "datev scanner",
|
||||
"score": 20,
|
||||
"avgReviews": 210,
|
||||
"top1": "DATEV Upload mobil (368 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "belegmanager",
|
||||
"score": 13,
|
||||
"avgReviews": 0,
|
||||
"top1": "Belegmanager: Belegthek (0 rev, ★0.0)",
|
||||
"resultCount": 4
|
||||
},
|
||||
{
|
||||
"keyword": "ausgaben tracker",
|
||||
"score": 21,
|
||||
"avgReviews": 781,
|
||||
"top1": "Haushaltsbuch Ausgaben Monee (437 rev, ★4.9)",
|
||||
"resultCount": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
"CH": {
|
||||
"name": "Schweiz",
|
||||
"data": [
|
||||
{
|
||||
"keyword": "beleg scanner",
|
||||
"score": 33,
|
||||
"avgReviews": 2143,
|
||||
"top1": "TurboScan™ (3556 rev, ★4.8)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "belege digitalisieren",
|
||||
"score": 12,
|
||||
"avgReviews": 26,
|
||||
"top1": "Spesen und Reisekosten App (80 rev, ★4.6)",
|
||||
"resultCount": 7
|
||||
},
|
||||
{
|
||||
"keyword": "kassenbon scanner",
|
||||
"score": 21,
|
||||
"avgReviews": 404,
|
||||
"top1": "epap Kassenbon & Haushaltsbuch (98 rev, ★4.5)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "quittung scanner",
|
||||
"score": 44,
|
||||
"avgReviews": 8381,
|
||||
"top1": "QuittungScanner: Klug Sparen (0 rev, ★0.0)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "spesen app",
|
||||
"score": 20,
|
||||
"avgReviews": 229,
|
||||
"top1": "Spesen und Reisekosten App (80 rev, ★4.6)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "rechnung scanner",
|
||||
"score": 44,
|
||||
"avgReviews": 10844,
|
||||
"top1": "Scanner Mini: Scannen & Fax (9875 rev, ★4.7)",
|
||||
"resultCount": 10
|
||||
},
|
||||
{
|
||||
"keyword": "receipt to excel",
|
||||
"score": 32,
|
||||
"avgReviews": 1695,
|
||||
"top1": "CamScanner - PDF Scanner App (8471 rev, ★4.7)",
|
||||
"resultCount": 7
|
||||
},
|
||||
{
|
||||
"keyword": "datev scanner",
|
||||
"score": 12,
|
||||
"avgReviews": 92,
|
||||
"top1": "DATEV Upload mobil (50 rev, ★4.7)",
|
||||
"resultCount": 9
|
||||
},
|
||||
{
|
||||
"keyword": "belegmanager",
|
||||
"score": 13,
|
||||
"avgReviews": 0,
|
||||
"top1": "Belegmanager: Belegthek (0 rev, ★0.0)",
|
||||
"resultCount": 4
|
||||
},
|
||||
{
|
||||
"keyword": "ausgaben tracker",
|
||||
"score": 21,
|
||||
"avgReviews": 456,
|
||||
"top1": "Haushaltsbuch Ausgaben Monee (366 rev, ★4.9)",
|
||||
"resultCount": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
90
scripts/multi_country_aso.mjs
Normal file
90
scripts/multi_country_aso.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import https from 'https';
|
||||
|
||||
const EN_KEYWORDS = [
|
||||
'receipt scanner',
|
||||
'receipt to excel',
|
||||
'receipt scanner to excel',
|
||||
'expense tracker',
|
||||
'receipt keeper',
|
||||
'invoice scanner',
|
||||
'tax receipts',
|
||||
'ocr receipt scanner',
|
||||
'smart receipts',
|
||||
'mileage and receipts',
|
||||
'receipt organizer',
|
||||
'bookkeeping scanner',
|
||||
'scan receipts for taxes',
|
||||
'extract receipt to csv',
|
||||
'business expense tracker'
|
||||
];
|
||||
|
||||
const DE_KEYWORDS = [
|
||||
'beleg scanner',
|
||||
'belege digitalisieren',
|
||||
'rechnung scanner',
|
||||
'kassenbon scanner',
|
||||
'quittung scanner',
|
||||
'spesen app',
|
||||
'buchhaltung scanner',
|
||||
'datev scanner',
|
||||
'receipt to excel',
|
||||
'haushaltsbuch',
|
||||
'ausgaben tracker',
|
||||
'steuer belege',
|
||||
'belegmanager'
|
||||
];
|
||||
|
||||
const COUNTRIES = {
|
||||
US: 'us',
|
||||
GB: 'gb',
|
||||
CA: 'ca',
|
||||
AU: 'au',
|
||||
DE: 'de'
|
||||
};
|
||||
|
||||
function fetchSearch(term, country) {
|
||||
return new Promise((resolve) => {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=10`;
|
||||
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
resolve({ results: [] });
|
||||
}
|
||||
});
|
||||
}).on('error', () => resolve({ results: [] }));
|
||||
});
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
console.log("Analyzing Multi-Country App Store data...");
|
||||
const countryResults = {};
|
||||
|
||||
for (const [cName, cCode] of Object.entries(COUNTRIES)) {
|
||||
countryResults[cName] = {};
|
||||
const kws = cName === 'DE' ? DE_KEYWORDS : EN_KEYWORDS;
|
||||
for (const kw of kws) {
|
||||
const data = await fetchSearch(kw, cCode);
|
||||
const apps = data.results || [];
|
||||
const top5 = apps.slice(0, 5);
|
||||
const top5Reviews = top5.reduce((sum, a) => sum + (a.userRatingCount || 0), 0);
|
||||
const avgTop5Reviews = Math.round(top5Reviews / (top5.length || 1));
|
||||
const top1 = top5[0] || {};
|
||||
|
||||
countryResults[cName][kw] = {
|
||||
resultCount: data.resultCount,
|
||||
top1Name: top1.trackName || 'N/A',
|
||||
top1Reviews: top1.userRatingCount || 0,
|
||||
avgTop5Reviews,
|
||||
};
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(countryResults, null, 2));
|
||||
}
|
||||
|
||||
analyze();
|
||||
116
scripts/probe_recommendations.mjs
Normal file
116
scripts/probe_recommendations.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
const ORG_ID = 22309440;
|
||||
|
||||
function generateClientSecret() {
|
||||
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function reqApi(path, token, method = 'GET', body = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-AP-Context': `orgId=${ORG_ID}`,
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'api.searchads.apple.com',
|
||||
port: 443,
|
||||
path,
|
||||
method,
|
||||
headers
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve({ raw: data, status: res.statusCode }); }
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => resolve({ error: e.message }));
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const secret = generateClientSecret();
|
||||
const token = (await getAccessToken(secret)).access_token;
|
||||
|
||||
// Let's test different paths for keyword suggestions
|
||||
const paths = [
|
||||
{ p: '/api/v5/keywords/recommendations', body: { adamId: 6759843546, countryOrRegionCode: 'US' } },
|
||||
{ p: '/api/v5/recommendations/keywords', body: { adamId: 6759843546, countryOrRegionCode: 'US' } },
|
||||
{ p: '/api/v5/campaigns/2144248407/adgroups/2149719430/targetingkeywords/find', body: { selector: { conditions: [] } } },
|
||||
{ p: '/api/v5/search/geo?query=receipt', body: null },
|
||||
{ p: '/api/v5/keywords/search/recommendations', body: { searchTerms: ['receipt'] } },
|
||||
{ p: '/api/v5/campaigns/2144248407/keywords/recommendations', body: { adamId: 6759843546 } }
|
||||
];
|
||||
|
||||
for (const item of paths) {
|
||||
const res = await reqApi(item.p, token, item.body ? 'POST' : 'GET', item.body);
|
||||
console.log(`\nPath ${item.p}:`, JSON.stringify(res, null, 2).slice(0, 400));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
151
scripts/query_apple_searchads.mjs
Normal file
151
scripts/query_apple_searchads.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
|
||||
function generateClientSecret() {
|
||||
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
sub: CLIENT_ID,
|
||||
aud: "https://appleid.apple.com",
|
||||
iat: now,
|
||||
exp: now + 86400 * 180,
|
||||
iss: TEAM_ID
|
||||
};
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const signature = signer.sign({
|
||||
key: PRIVATE_KEY.replace(/\\n/g, '\n'),
|
||||
dsaEncoding: 'ieee-p1363'
|
||||
}, "base64url");
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const options = {
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function requestAppleApi(path, token, orgId, method = 'GET', body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
if (orgId) {
|
||||
headers['X-AP-Context'] = `orgId=${orgId}`;
|
||||
}
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const options = {
|
||||
hostname: 'api.searchads.apple.com',
|
||||
port: 443,
|
||||
path: path,
|
||||
method: method,
|
||||
headers: headers
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const secret = generateClientSecret();
|
||||
const tokenRes = await getAccessToken(secret);
|
||||
const token = tokenRes.access_token;
|
||||
|
||||
console.log("Abfrage /api/v5/acls für orgId...");
|
||||
const acls = await requestAppleApi('/api/v5/acls', token);
|
||||
console.log("ACLs:", JSON.stringify(acls, null, 2));
|
||||
|
||||
const org = acls?.data?.[0];
|
||||
const orgId = org?.orgId;
|
||||
console.log("Verwende OrgId:", orgId);
|
||||
|
||||
if (orgId) {
|
||||
// In Search Ads API v5: POST /api/v5/keywords/search
|
||||
console.log("\nAbfrage Keywords via POST /api/v5/keywords/search ...");
|
||||
const keywords = ['receipt scanner', 'receipt to excel', 'expense tracker', 'beleg scanner'];
|
||||
const testKw = await requestAppleApi('/api/v5/keywords/search', token, orgId, 'POST', {
|
||||
searchTerms: keywords,
|
||||
countryOrRegion: 'US'
|
||||
});
|
||||
console.log("Keyword Search Result (US):", JSON.stringify(testKw, null, 2));
|
||||
|
||||
// Check DE
|
||||
const testKwDE = await requestAppleApi('/api/v5/keywords/search', token, orgId, 'POST', {
|
||||
searchTerms: ['beleg scanner', 'belege digitalisieren', 'kassenbon scanner', 'haushaltsbuch'],
|
||||
countryOrRegion: 'DE'
|
||||
});
|
||||
console.log("Keyword Search Result (DE):", JSON.stringify(testKwDE, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
112
scripts/query_campaign_keywords.mjs
Normal file
112
scripts/query_campaign_keywords.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
const ORG_ID = 22309440;
|
||||
|
||||
function generateClientSecret() {
|
||||
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function reqApi(path, token, method = 'GET', body = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-AP-Context': `orgId=${ORG_ID}`,
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'api.searchads.apple.com',
|
||||
port: 443,
|
||||
path,
|
||||
method,
|
||||
headers
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve({ raw: data, status: res.statusCode }); }
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => resolve({ error: e.message }));
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const secret = generateClientSecret();
|
||||
const token = (await getAccessToken(secret)).access_token;
|
||||
|
||||
console.log("Abfrage Kampagne 2144248407 Adgroups...");
|
||||
const adgroups = await reqApi('/api/v5/campaigns/2144248407/adgroups', token);
|
||||
console.log("Adgroups:", JSON.stringify(adgroups, null, 2));
|
||||
|
||||
const adgroupId = adgroups?.data?.[0]?.id;
|
||||
if (adgroupId) {
|
||||
console.log(`\nAbfrage Keywords für AdGroup ${adgroupId}...`);
|
||||
const kwRes = await reqApi(`/api/v5/campaigns/2144248407/adgroups/${adgroupId}/targetingkeywords`, token);
|
||||
console.log("Targeting Keywords:", JSON.stringify(kwRes, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
21
scripts/register-next-server-resolve.mjs
Normal file
21
scripts/register-next-server-resolve.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Node.js resolve hook used ONLY by scripts/verify_sensitive_paths.mjs when run
|
||||
* with plain `node` (no bundler, no tsx).
|
||||
*
|
||||
* Next ships `next/server.js` but no package.json "exports" map, so bundlers
|
||||
* resolve `next/server` by extension-guessing while plain Node ESM does not.
|
||||
* This hook remaps the bare specifier `next/server` -> `next/server.js` for the
|
||||
* duration of the test process. Not used by the app itself.
|
||||
*
|
||||
* Run: node --import ./scripts/register-next-server-resolve.mjs scripts/verify_sensitive_paths.mjs
|
||||
*/
|
||||
import { registerHooks } from "node:module";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === "next/server") {
|
||||
return nextResolve("next/server.js", context);
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
24
scripts/search_models.mjs
Normal file
24
scripts/search_models.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import fs from "fs";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
async function searchModels() {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const models = data.data || [];
|
||||
|
||||
const lunaModels = models.filter(m => m.id.toLowerCase().includes("luna") || m.id.toLowerCase().includes("gpt-5"));
|
||||
console.log("Found matches for 'luna' or 'gpt-5':", lunaModels.map(m => ({ id: m.id, name: m.name, pricing: m.pricing })));
|
||||
|
||||
const visionModels = models.filter(m => m.architecture && m.architecture.modality && m.architecture.modality.includes("image->text"));
|
||||
console.log(`Total vision models on OpenRouter: ${visionModels.length}`);
|
||||
}
|
||||
|
||||
searchModels().catch(console.error);
|
||||
115
scripts/test_adgroup_recs.mjs
Normal file
115
scripts/test_adgroup_recs.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
const ORG_ID = 22309440;
|
||||
|
||||
function generateClientSecret() {
|
||||
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function reqApi(path, token, method = 'GET', body = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-AP-Context': `orgId=${ORG_ID}`,
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'api.searchads.apple.com',
|
||||
port: 443,
|
||||
path,
|
||||
method,
|
||||
headers
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve({ raw: data, status: res.statusCode }); }
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => resolve({ error: e.message }));
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const secret = generateClientSecret();
|
||||
const token = (await getAccessToken(secret)).access_token;
|
||||
|
||||
const endpoints = [
|
||||
{ p: '/api/v5/campaigns/2144248407/adgroups/2149719430/targetingkeywords/recommendations', body: {} },
|
||||
{ p: '/api/v5/campaigns/2144248407/adgroups/2149719430/keywords/recommendations', body: {} },
|
||||
{ p: '/api/v5/campaigns/2144248407/keywords/recommendations', body: {} },
|
||||
{ p: '/api/v5/adgroups/2149719430/targetingkeywords/recommendations', body: {} },
|
||||
{ p: '/api/v5/reports/campaigns/2144248407/keywords', body: { startTime: '2026-07-01', endTime: '2026-08-18', selector: { orderBy: [{ field: 'impressions', sortOrder: 'DESCENDING' }] } } }
|
||||
];
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const res = await reqApi(ep.p, token, 'POST', ep.body);
|
||||
console.log(`\nEndpoint ${ep.p}:`);
|
||||
console.log(JSON.stringify(res, null, 2).slice(0, 400));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
133
scripts/test_ads_platform_api.mjs
Normal file
133
scripts/test_ads_platform_api.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
const ORG_ID = 22309440;
|
||||
|
||||
function generateClientSecret() {
|
||||
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function reqAdsApi(path, token, method = 'POST', body = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-AP-Context': `orgId=${ORG_ID}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'api.ads.apple.com',
|
||||
port: 443,
|
||||
path,
|
||||
method,
|
||||
headers
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve({ raw: data, status: res.statusCode }); }
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => resolve({ error: e.message }));
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const secret = generateClientSecret();
|
||||
const token = (await getAccessToken(secret)).access_token;
|
||||
|
||||
console.log("Token obtained. Testing api.ads.apple.com endpoints...");
|
||||
|
||||
const endpoints = [
|
||||
{
|
||||
p: '/v1/connect/apple-ads/insights/search-term-popularity',
|
||||
body: {
|
||||
countryOrRegionCode: 'US',
|
||||
searchTerms: ['receipt scanner', 'receipt to excel', 'expense tracker', 'invoice scanner']
|
||||
}
|
||||
},
|
||||
{
|
||||
p: '/v1/connect/apple-ads/suggestions/keywords',
|
||||
body: {
|
||||
countryOrRegionCode: 'US',
|
||||
searchTerm: 'receipt scanner'
|
||||
}
|
||||
},
|
||||
{
|
||||
p: '/v1/connect/apple-ads/suggestions/phrases',
|
||||
body: {
|
||||
countryOrRegionCode: 'US',
|
||||
searchTerm: 'receipt to excel'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const res = await reqAdsApi(ep.p, token, 'POST', ep.body);
|
||||
console.log(`\nEndpoint ${ep.p}:`);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
112
scripts/test_apple_searchads_api.mjs
Normal file
112
scripts/test_apple_searchads_api.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
// Read .env.local manually
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
|
||||
function generateClientSecret() {
|
||||
if (!PRIVATE_KEY || PRIVATE_KEY.trim() === '') {
|
||||
throw new Error("APPLE_SEARCH_ADS_PRIVATE_KEY ist nicht in .env.local gesetzt!");
|
||||
}
|
||||
|
||||
const header = {
|
||||
alg: "ES256",
|
||||
kid: KEY_ID,
|
||||
typ: "JWT"
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
sub: CLIENT_ID,
|
||||
aud: "https://appleid.apple.com",
|
||||
iat: now,
|
||||
exp: now + 86400 * 180, // 180 Tage
|
||||
iss: TEAM_ID
|
||||
};
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const formattedKey = PRIVATE_KEY.replace(/\\n/g, '\n');
|
||||
const signature = signer.sign({
|
||||
key: formattedKey,
|
||||
dsaEncoding: 'ieee-p1363'
|
||||
}, "base64url");
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const options = {
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(new Error(`Parse error: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("1. Generiere Client Secret (JWT)...");
|
||||
const clientSecret = generateClientSecret();
|
||||
console.log("Client Secret erfolgreich generiert.");
|
||||
|
||||
console.log("2. Fordere Access Token bei Apple OAuth an...");
|
||||
const tokenData = await getAccessToken(clientSecret);
|
||||
console.log("Antwort von Apple:", tokenData);
|
||||
} catch (err) {
|
||||
console.error("Fehler:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
117
scripts/test_endpoints.mjs
Normal file
117
scripts/test_endpoints.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
const ORG_ID = 22309440;
|
||||
|
||||
function generateClientSecret() {
|
||||
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function reqApi(path, token, method = 'GET', body = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-AP-Context': `orgId=${ORG_ID}`,
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'api.searchads.apple.com',
|
||||
port: 443,
|
||||
path,
|
||||
method,
|
||||
headers
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve({ raw: data, status: res.statusCode }); }
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => resolve({ error: e.message }));
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const secret = generateClientSecret();
|
||||
const token = (await getAccessToken(secret)).access_token;
|
||||
|
||||
console.log("Token obtained. Testing endpoints...");
|
||||
|
||||
// Test endpoints for keyword search
|
||||
const endpoints = [
|
||||
{ path: '/api/v5/search/keywords', method: 'POST', body: { term: 'receipt', countryOrRegion: 'US' } },
|
||||
{ path: '/api/v5/keywords/targeting-keywords', method: 'POST', body: { searchTerms: ['receipt'], countryOrRegion: 'US' } },
|
||||
{ path: '/api/v5/campaigns', method: 'GET', body: null },
|
||||
{ path: '/api/v5/search/apps?query=receipt', method: 'GET', body: null }
|
||||
];
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const res = await reqApi(ep.path, token, ep.method, ep.body);
|
||||
console.log(`\nEndpoint ${ep.method} ${ep.path}:`);
|
||||
console.log(JSON.stringify(res, null, 2).slice(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
29
scripts/test_extractor.ts
Normal file
29
scripts/test_extractor.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import fs from "fs";
|
||||
import { extractReceiptData } from "../src/lib/ai/extractor";
|
||||
|
||||
const content = fs.readFileSync(".env.local", "utf8");
|
||||
for (const l of content.split("\n")) {
|
||||
const trimmed = l.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx !== -1) {
|
||||
process.env[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
console.log("Model:", process.env.OPENROUTER_MODEL);
|
||||
console.log("Key prefix:", process.env.OPENROUTER_API_KEY?.substring(0, 10));
|
||||
|
||||
// Test demo fallback
|
||||
const start = Date.now();
|
||||
try {
|
||||
const dummyBase64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const res = await extractReceiptData({ base64DataUrl: dummyBase64, fileName: "01_aral_tankbeleg.jpg" });
|
||||
console.log("Result in " + (Date.now() - start) + "ms:", res.merchant.name, res.totalAmount.value);
|
||||
} catch (e) {
|
||||
console.error("Test error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
27
scripts/test_extractor_live.mjs
Normal file
27
scripts/test_extractor_live.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import fs from "fs";
|
||||
import { extractReceiptData } from "../src/lib/ai/extractor.ts";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
process.env.OPENROUTER_API_KEY = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
|
||||
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
|
||||
|
||||
async function testExtractorLive() {
|
||||
console.log("=== TESTING LIVE EXTRACTOR WITH OPENROUTER ===");
|
||||
const result = await extractReceiptData({
|
||||
base64DataUrl: dataUrl,
|
||||
fileName: "random_real_receipt_photo.jpg"
|
||||
});
|
||||
|
||||
console.log("Extracted Merchant:", result.merchant.name);
|
||||
console.log("Extracted Gross Total:", result.totalAmount.value, result.currency);
|
||||
console.log("Extracted Date:", result.date.isoDate);
|
||||
console.log("Extracted Tax Breakdown:", JSON.stringify(result.taxBreakdown));
|
||||
console.log("Extracted Line Items:", JSON.stringify(result.lineItems));
|
||||
console.log("Validation:", JSON.stringify(result.validation));
|
||||
}
|
||||
|
||||
testExtractorLive().catch(console.error);
|
||||
39
scripts/test_gpt5.mjs
Normal file
39
scripts/test_gpt5.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import fs from "fs";
|
||||
import { generateObject } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
|
||||
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
|
||||
|
||||
async function testGpt5Models() {
|
||||
const models = ["openai/gpt-5-mini", "openai/gpt-5.4-mini", "openai/gpt-4o-mini"];
|
||||
const openrouter = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
|
||||
for (const m of models) {
|
||||
console.log(`\nTesting ${m}...`);
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: openrouter(m),
|
||||
schema: ReceiptExtractionSchema,
|
||||
messages: [
|
||||
{ role: "system", content: "Extrahiere den Beleg." },
|
||||
{ role: "user", content: [{ type: "text", text: "Beleg:" }, { type: "image", image: dataUrl }] },
|
||||
],
|
||||
});
|
||||
console.log(`✓ SUCCESS with ${m}! Merchant: ${object.merchant.name}, Total: ${object.totalAmount.value}`);
|
||||
} catch (err) {
|
||||
console.log(`✗ Error with ${m}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testGpt5Models().catch(console.error);
|
||||
67
scripts/test_hints.mjs
Normal file
67
scripts/test_hints.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import https from 'https';
|
||||
|
||||
const keywords = [
|
||||
// German Core
|
||||
'receipt scanner',
|
||||
'beleg scanner',
|
||||
'belege digitalisieren',
|
||||
'rechnung scanner',
|
||||
'kassenbon scanner',
|
||||
'quittung scanner',
|
||||
'spesen app',
|
||||
'buchhaltung scanner',
|
||||
'datev',
|
||||
'datev scanner',
|
||||
'belegmanager',
|
||||
'haushaltsbuch',
|
||||
'ausgaben tracker',
|
||||
'steuer belege',
|
||||
|
||||
// English Core
|
||||
'receipt to excel',
|
||||
'receipt scanner to excel',
|
||||
'expense tracker',
|
||||
'receipt keeper',
|
||||
'invoice scanner',
|
||||
'smart receipts',
|
||||
'receipts',
|
||||
'shoeboxed',
|
||||
'expensify',
|
||||
'camscanner',
|
||||
'genius scan'
|
||||
];
|
||||
|
||||
function fetchHints(term, storefront = '143443') { // 143443 = Germany, 143441 = US
|
||||
return new Promise((resolve) => {
|
||||
const url = `https://search.itunes.apple.com/WebObjects/MZSearchHints.woa/wa/hints?clientApplication=Software&term=${encodeURIComponent(term)}`;
|
||||
https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'AppStore/3.0 (iOS; 16.0)',
|
||||
'X-Apple-Store-Front': `${storefront}-1,29`
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
resolve(json);
|
||||
} catch (e) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}).on('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
async function test() {
|
||||
console.log('Testing hints:');
|
||||
for (const kw of ['beleg', 'kassenbon', 'receipt', 'expense', 'datev', 'spesen', 'rechnung']) {
|
||||
const hintsDE = await fetchHints(kw, '143443');
|
||||
console.log(`DE Hints for "${kw}":`, hintsDE?.hints?.map(h => h.term));
|
||||
const hintsUS = await fetchHints(kw, '143441');
|
||||
console.log(`US Hints for "${kw}":`, hintsUS?.hints?.map(h => h.term));
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
59
scripts/test_json_mode.mjs
Normal file
59
scripts/test_json_mode.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from "fs";
|
||||
import { generateObject } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
|
||||
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
|
||||
|
||||
async function testJsonMode() {
|
||||
console.log("Testing generateObject with mode: 'json' on OpenRouter...");
|
||||
|
||||
const openrouter = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
headers: {
|
||||
"HTTP-Referer": "http://localhost:3000",
|
||||
"X-Title": "Receipt Scanner to Excel",
|
||||
},
|
||||
});
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: openrouter("openai/gpt-4o-mini"),
|
||||
schema: ReceiptExtractionSchema,
|
||||
mode: "json",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Du bist ein hochpräziser KI-Belegscanner. Extrahiere alle Belegdaten vollständig im vorgegebenen JSON-Format:
|
||||
- merchant (name, address, taxId, confidence 0-1)
|
||||
- date (isoDate YYYY-MM-DD, time, confidence 0-1)
|
||||
- documentType (KASSENBON, RECHNUNG, TANKBELEG, BEWIRTUNGSBELEG, PARKTICKET, SONSTIGES)
|
||||
- receiptNumber
|
||||
- currency (EUR)
|
||||
- totalAmount (value, confidence 0-1)
|
||||
- netAmount
|
||||
- taxBreakdown (array mit ratePercent, taxAmount, netAmount)
|
||||
- lineItems (array mit description, quantity, price, taxRate)
|
||||
- suggestedCategory (Bewirtung, Reisekosten & Hotel, Tanken & KFZ, Bürobedarf & IT, Verpflegungsmehraufwand, Material & Einkauf, Sonstiges)
|
||||
- validation (isMathValid, isDuplicateSuspected, needsUserReview, reviewField, reviewReason)`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Extrahiere diesen Beleg vollständig als JSON:" },
|
||||
{ type: "image", image: dataUrl },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log("✓ SUCCESS! Extracted Object:\n", JSON.stringify(object, null, 2));
|
||||
}
|
||||
|
||||
testJsonMode().catch(console.error);
|
||||
48
scripts/test_luna.mjs
Normal file
48
scripts/test_luna.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import fs from "fs";
|
||||
import { generateObject } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
|
||||
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
|
||||
|
||||
async function testLuna() {
|
||||
console.log("Testing openai/gpt-5.6-luna on OpenRouter...");
|
||||
|
||||
const openrouter = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
headers: {
|
||||
"HTTP-Referer": "http://localhost:3000",
|
||||
"X-Title": "Receipt Scanner to Excel",
|
||||
},
|
||||
});
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: openrouter("openai/gpt-5.6-luna"),
|
||||
schema: ReceiptExtractionSchema,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Du bist ein präziser Belegscanner. Extrahiere alle Daten im Schema.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Extrahiere diesen Beleg vollständig:" },
|
||||
{ type: "image", image: dataUrl },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log("✓ SUCCESS WITH GPT-5.6-LUNA!");
|
||||
console.log("Extracted:", JSON.stringify(object, null, 2));
|
||||
}
|
||||
|
||||
testLuna().catch(console.error);
|
||||
36
scripts/test_openrouter.mjs
Normal file
36
scripts/test_openrouter.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
console.log("Testing OpenRouter with key:", apiKey ? apiKey.substring(0, 15) + "..." : "NONE");
|
||||
|
||||
async function testOpenRouter() {
|
||||
// Test 1: List models or check deepseek/deepseek-v4-flash
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "http://localhost:3000",
|
||||
"X-Title": "Receipt Scanner",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Sag einfach 'Hallo, DeepSeek V4 Flash ist bereit!'"
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
console.log("Status:", res.status, res.statusText);
|
||||
const data = await res.json();
|
||||
console.log("Response:", JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
testOpenRouter().catch(console.error);
|
||||
116
scripts/test_recommendations.mjs
Normal file
116
scripts/test_recommendations.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env.local', 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
let val = match[2].trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
env[match[1].trim()] = val;
|
||||
}
|
||||
});
|
||||
|
||||
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
|
||||
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
|
||||
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
|
||||
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
|
||||
const ORG_ID = 22309440;
|
||||
|
||||
function generateClientSecret() {
|
||||
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
|
||||
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const signer = crypto.createSign("SHA256");
|
||||
signer.update(signingInput);
|
||||
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getAccessToken(clientSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret,
|
||||
scope: 'searchadsorg'
|
||||
}).toString();
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'appleid.apple.com',
|
||||
port: 443,
|
||||
path: '/auth/oauth2/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function reqApi(path, token, method = 'GET', body = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-AP-Context': `orgId=${ORG_ID}`,
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'api.searchads.apple.com',
|
||||
port: 443,
|
||||
path,
|
||||
method,
|
||||
headers
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve({ raw: data, status: res.statusCode }); }
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => resolve({ error: e.message }));
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const secret = generateClientSecret();
|
||||
const token = (await getAccessToken(secret)).access_token;
|
||||
|
||||
console.log("Token obtained. Testing keyword recommendation endpoints...");
|
||||
|
||||
const endpoints = [
|
||||
{ path: '/api/v5/keywords/recommendation', method: 'POST', body: { adamId: 6759843546, countryOrRegionCode: 'US' } },
|
||||
{ path: '/api/v5/keywords/recommendation', method: 'POST', body: { adamId: 6759843546, countryOrRegionCode: 'DE' } },
|
||||
{ path: '/api/v5/keywords/recommendation', method: 'POST', body: { searchTerms: ['receipt scanner', 'receipt to excel', 'expense tracker'], countryOrRegionCode: 'US' } },
|
||||
{ path: '/api/v5/keywords/search/recommendations', method: 'POST', body: { searchTerms: ['receipt scanner'], countryOrRegionCode: 'US' } }
|
||||
];
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const res = await reqApi(ep.path, token, ep.method, ep.body);
|
||||
console.log(`\nEndpoint ${ep.method} ${ep.path}:`);
|
||||
console.log(JSON.stringify(res, null, 2).slice(0, 500));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
47
scripts/test_structured.mjs
Normal file
47
scripts/test_structured.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import fs from "fs";
|
||||
import { generateObject } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
|
||||
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
|
||||
|
||||
async function testStructuredExtraction() {
|
||||
console.log("Testing structured object extraction with OpenRouter openai/gpt-4o-mini...");
|
||||
|
||||
const openrouter = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
headers: {
|
||||
"HTTP-Referer": "http://localhost:3000",
|
||||
"X-Title": "Receipt Scanner to Excel",
|
||||
},
|
||||
});
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: openrouter("openai/gpt-4o-mini"),
|
||||
schema: ReceiptExtractionSchema,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Du bist ein präziser Belegscanner. Extrahiere alle Daten im vorgegebenen Schema.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Extrahiere diesen Beleg vollständig:" },
|
||||
{ type: "image", image: dataUrl },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log("Extracted Object:", JSON.stringify(object, null, 2));
|
||||
}
|
||||
|
||||
testStructuredExtraction().catch(console.error);
|
||||
47
scripts/test_vision.mjs
Normal file
47
scripts/test_vision.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const envContent = fs.readFileSync(".env.local", "utf-8");
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
|
||||
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
|
||||
|
||||
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
|
||||
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
|
||||
|
||||
async function testVision() {
|
||||
console.log("Testing image input with deepseek/deepseek-v4-flash on OpenRouter...");
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "http://localhost:3000",
|
||||
"X-Title": "Receipt Scanner",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Welcher Händler steht auf diesem Beleg und wie lautet der Gesamtbetrag?" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: dataUrl
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
console.log("Status:", res.status, res.statusText);
|
||||
const data = await res.json();
|
||||
console.log("Response:", JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
testVision().catch(console.error);
|
||||
263
scripts/verify-db-permissions.mjs
Normal file
263
scripts/verify-db-permissions.mjs
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify the database least-privilege setup (scripts/db-permissions.sql).
|
||||
*
|
||||
* Connects twice:
|
||||
* 1. as the OWNER (DATABASE_URL from env, .env.local or .env) to inspect the
|
||||
* role attributes and privilege catalog, and
|
||||
* 2. as the RUNTIME role `receipt_app` (APP_DATABASE_URL if set, otherwise
|
||||
* derived from DATABASE_URL with user receipt_app and the runtime
|
||||
* password) to prove the role can do what the app needs — and cannot do
|
||||
* DDL.
|
||||
*
|
||||
* Prints one PASS/FAIL line per check and exits 1 if any check fails.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-db-permissions.mjs
|
||||
* APP_DATABASE_URL=... node scripts/verify-db-permissions.mjs
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "pg";
|
||||
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_RUNTIME_PASSWORD = "receipt_app_secure_password";
|
||||
const RUNTIME_ROLE = "receipt_app";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(name, ok, detail = "") {
|
||||
if (ok) {
|
||||
passed++;
|
||||
console.log(`[PASS] ${name}${detail ? ` - ${detail}` : ""}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(`[FAIL] ${name}${detail ? ` - ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal .env parser: strips quotes, ignores comments; first key wins. */
|
||||
function parseEnvFile(filePath) {
|
||||
const out = {};
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in out)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Env precedence: process.env > .env.local > .env.
|
||||
const fileEnv = { ...parseEnvFile(path.join(ROOT, ".env")), ...parseEnvFile(path.join(ROOT, ".env.local")) };
|
||||
|
||||
const ownerUrl = process.env.DATABASE_URL || fileEnv.DATABASE_URL;
|
||||
if (!ownerUrl) {
|
||||
console.error("FATAL: DATABASE_URL not found. Set it in the environment, .env.local or .env.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runtimePassword =
|
||||
process.env.APP_DATABASE_PASSWORD || fileEnv.APP_DATABASE_PASSWORD || DEFAULT_RUNTIME_PASSWORD;
|
||||
|
||||
/** Runtime URL: APP_DATABASE_URL when provided, else derived from the owner URL. */
|
||||
function runtimeUrl() {
|
||||
const explicit =
|
||||
process.env.APP_DATABASE_URL || fileEnv.APP_DATABASE_URL;
|
||||
if (explicit) return explicit;
|
||||
const u = new URL(ownerUrl);
|
||||
u.username = RUNTIME_ROLE;
|
||||
u.password = runtimePassword;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
console.log(`Verifying least-privilege setup for role "${RUNTIME_ROLE}" ...\n`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1) Owner connection: role attributes + privilege catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
const owner = new Client({ connectionString: ownerUrl });
|
||||
await owner.connect();
|
||||
|
||||
const roleRes = await owner.query(
|
||||
`SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin
|
||||
FROM pg_roles WHERE rolname = $1`,
|
||||
[RUNTIME_ROLE]
|
||||
);
|
||||
if (roleRes.rows.length === 0) {
|
||||
check(`${RUNTIME_ROLE} exists`, false, "role not found");
|
||||
console.error("\nAborting: runtime role is missing. Run: node scripts/apply-db-permissions.mjs");
|
||||
await owner.end();
|
||||
process.exit(1);
|
||||
}
|
||||
const r = roleRes.rows[0];
|
||||
check(`${RUNTIME_ROLE} exists with LOGIN`, r.rolcanlogin === true);
|
||||
check(`${RUNTIME_ROLE} is NOT a superuser`, r.rolsuper === false);
|
||||
check(`${RUNTIME_ROLE} has no CREATEDB`, r.rolcreatedb === false);
|
||||
check(`${RUNTIME_ROLE} has no CREATEROLE`, r.rolcreaterole === false);
|
||||
|
||||
const privRes = await owner.query(
|
||||
`SELECT has_database_privilege($1, current_database(), 'CONNECT') AS db_connect,
|
||||
has_database_privilege($1, current_database(), 'TEMP') AS db_temp,
|
||||
has_schema_privilege($1, 'public', 'USAGE') AS schema_usage,
|
||||
has_schema_privilege($1, 'public', 'CREATE') AS schema_create`,
|
||||
[RUNTIME_ROLE]
|
||||
);
|
||||
const p = privRes.rows[0];
|
||||
check(`${RUNTIME_ROLE} has CONNECT on the database`, p.db_connect === true);
|
||||
check(`${RUNTIME_ROLE} has USAGE on schema public`, p.schema_usage === true);
|
||||
check(`${RUNTIME_ROLE} has NO CREATE on schema public (no DDL)`, p.schema_create === false);
|
||||
|
||||
const publicRes = await owner.query(
|
||||
`SELECT has_schema_privilege('public', 'public', 'CREATE') AS public_create`
|
||||
);
|
||||
check(
|
||||
"PUBLIC pseudo-role has NO CREATE on schema public (schema hardened)",
|
||||
publicRes.rows[0].public_create === false
|
||||
);
|
||||
|
||||
// All current public tables: DML granted, DDL (TRUNCATE/REFERENCES) NOT granted.
|
||||
const tableRes = await owner.query(
|
||||
`SELECT c.relname,
|
||||
has_table_privilege($1, c.oid, 'SELECT') AS can_select,
|
||||
has_table_privilege($1, c.oid, 'INSERT') AS can_insert,
|
||||
has_table_privilege($1, c.oid, 'UPDATE') AS can_update,
|
||||
has_table_privilege($1, c.oid, 'DELETE') AS can_delete,
|
||||
has_table_privilege($1, c.oid, 'TRUNCATE') AS can_truncate,
|
||||
has_table_privilege($1, c.oid, 'REFERENCES') AS can_references
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'r'
|
||||
ORDER BY c.relname`,
|
||||
[RUNTIME_ROLE]
|
||||
);
|
||||
const missingDml = tableRes.rows.filter((t) => !(t.can_select && t.can_insert && t.can_update && t.can_delete));
|
||||
check(
|
||||
`${RUNTIME_ROLE} has SELECT/INSERT/UPDATE/DELETE on ALL public tables`,
|
||||
missingDml.length === 0,
|
||||
`${tableRes.rows.length} table(s)` +
|
||||
(missingDml.length ? `; missing on: ${missingDml.map((t) => t.relname).join(", ")}` : "")
|
||||
);
|
||||
const grantedDdl = tableRes.rows.filter((t) => t.can_truncate || t.can_references);
|
||||
check(
|
||||
`${RUNTIME_ROLE} has NO TRUNCATE/REFERENCES on tables (no DDL)`,
|
||||
grantedDdl.length === 0,
|
||||
grantedDdl.length ? `granted on: ${grantedDdl.map((t) => t.relname).join(", ")}` : `${tableRes.rows.length} table(s) checked`
|
||||
);
|
||||
|
||||
// All current public sequences: USAGE/SELECT granted, UPDATE (setval) NOT granted.
|
||||
const seqRes = await owner.query(
|
||||
`SELECT c.relname,
|
||||
has_sequence_privilege($1, c.oid, 'USAGE') AS can_usage,
|
||||
has_sequence_privilege($1, c.oid, 'SELECT') AS can_select,
|
||||
has_sequence_privilege($1, c.oid, 'UPDATE') AS can_update
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'S'
|
||||
ORDER BY c.relname`,
|
||||
[RUNTIME_ROLE]
|
||||
);
|
||||
const missingSeq = seqRes.rows.filter((s) => !(s.can_usage && s.can_select));
|
||||
check(
|
||||
`${RUNTIME_ROLE} has USAGE/SELECT on ALL public sequences`,
|
||||
missingSeq.length === 0,
|
||||
`${seqRes.rows.length} sequence(s)` +
|
||||
(missingSeq.length ? `; missing on: ${missingSeq.map((s) => s.relname).join(", ")}` : "")
|
||||
);
|
||||
const updateSeq = seqRes.rows.filter((s) => s.can_update);
|
||||
check(
|
||||
`${RUNTIME_ROLE} has NO UPDATE on sequences (no setval/rewind)`,
|
||||
updateSeq.length === 0,
|
||||
updateSeq.length ? `granted on: ${updateSeq.map((s) => s.relname).join(", ")}` : `${seqRes.rows.length} sequence(s) checked`
|
||||
);
|
||||
|
||||
// Default privileges: future objects created by the owner get the same grants.
|
||||
const defRes = await owner.query(
|
||||
`SELECT p.defaclobjtype, array_to_string(p.defaclacl, ',') AS acl
|
||||
FROM pg_default_acl p
|
||||
JOIN pg_namespace n ON n.oid = p.defaclnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND pg_get_userbyid(p.defaclrole) = current_user`
|
||||
);
|
||||
const aclText = defRes.rows.map((x) => `${x.defaclobjtype}:${x.acl}`).join(" | ");
|
||||
// Table default ACL is "receipt_app=arwd/<grantor>", sequence is "receipt_app=rU/<grantor>".
|
||||
check(
|
||||
"DEFAULT PRIVILEGES grant DML to receipt_app for future tables",
|
||||
aclText.includes("receipt_app=arwd"),
|
||||
aclText || "no default ACLs recorded"
|
||||
);
|
||||
check(
|
||||
"DEFAULT PRIVILEGES grant USAGE/SELECT on future sequences",
|
||||
aclText.includes("receipt_app=rU"),
|
||||
aclText || "no default ACLs recorded"
|
||||
);
|
||||
|
||||
// Owner must still be able to DDL (migrations/schema init unaffected).
|
||||
try {
|
||||
await owner.query("CREATE TABLE public.__receipt_owner_ddl_check__(id integer)");
|
||||
await owner.query("DROP TABLE public.__receipt_owner_ddl_check__");
|
||||
check("owner role can still CREATE/DROP tables (migrations unaffected)", true, "CREATE + DROP in public succeeded");
|
||||
} catch (err) {
|
||||
check("owner role can still CREATE/DROP tables (migrations unaffected)", false, err.message);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) Runtime connection as receipt_app
|
||||
// ---------------------------------------------------------------------------
|
||||
const appUrl = runtimeUrl();
|
||||
const app = new Client({ connectionString: appUrl });
|
||||
let connected = false;
|
||||
try {
|
||||
await app.connect();
|
||||
connected = true;
|
||||
check("receipt_app can connect with its own credentials", true, "(CONNECT granted; URL from APP_DATABASE_URL or derived from DATABASE_URL)");
|
||||
|
||||
const me = await app.query("SELECT current_user AS u, current_database() AS db");
|
||||
check("connected session runs as receipt_app", me.rows[0].u === RUNTIME_ROLE, `current_user=${me.rows[0].u}, db=${me.rows[0].db}`);
|
||||
|
||||
try {
|
||||
const cnt = await app.query("SELECT count(*) AS n FROM users");
|
||||
check("receipt_app can SELECT from users", true, `count(*) = ${cnt.rows[0].n}`);
|
||||
} catch (err) {
|
||||
check("receipt_app can SELECT from users", false, err.message);
|
||||
}
|
||||
|
||||
// DDL attempt: must be denied (SQLSTATE 42501 = insufficient_privilege).
|
||||
try {
|
||||
await app.query("CREATE TABLE public.__receipt_app_perm_check__(id integer)");
|
||||
await app.query("DROP TABLE public.__receipt_app_perm_check__").catch(() => {});
|
||||
check("receipt_app CANNOT create tables (DDL denied)", false, "CREATE TABLE unexpectedly succeeded");
|
||||
} catch (err) {
|
||||
if (err.code === "42501") {
|
||||
check("receipt_app CANNOT create tables (DDL denied)", true, `SQLSTATE 42501 insufficient_privilege`);
|
||||
} else {
|
||||
check("receipt_app CANNOT create tables (DDL denied)", false, `unexpected error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
check("receipt_app can connect with its own credentials", false, err.message);
|
||||
} finally {
|
||||
if (connected) await app.end().catch(() => {});
|
||||
}
|
||||
|
||||
await owner.end();
|
||||
|
||||
console.log(`\nResult: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
50
scripts/verify-styles.js
Normal file
50
scripts/verify-styles.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function walk(dir) {
|
||||
let results = [];
|
||||
fs.readdirSync(dir).forEach((file) => {
|
||||
const full = path.join(dir, file);
|
||||
if (fs.statSync(full).isDirectory()) {
|
||||
results = results.concat(walk(full));
|
||||
} else if (/\.(tsx|ts|css)$/.test(file)) {
|
||||
results.push(full);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
const files = walk('src');
|
||||
let violations = 0;
|
||||
|
||||
files.forEach((f) => {
|
||||
const lines = fs.readFileSync(f, 'utf8').split('\n');
|
||||
lines.forEach((l, i) => {
|
||||
if (/\bshadow-(sm|md|lg|xl|2xl)\b/.test(l)) {
|
||||
console.log(`SHADOW: ${f}:${i + 1} -> ${l.trim()}`);
|
||||
violations++;
|
||||
}
|
||||
if (/\brounded-(full|lg|md|xl|2xl|3xl)\b/.test(l)) {
|
||||
console.log(`ROUNDED: ${f}:${i + 1} -> ${l.trim()}`);
|
||||
violations++;
|
||||
}
|
||||
if (/\bbg-gradient\b/.test(l)) {
|
||||
console.log(`GRADIENT: ${f}:${i + 1} -> ${l.trim()}`);
|
||||
violations++;
|
||||
}
|
||||
if (/box-shadow:/.test(l)) {
|
||||
console.log(`BOX-SHADOW: ${f}:${i + 1} -> ${l.trim()}`);
|
||||
violations++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('--- VERIFICATION RESULT ---');
|
||||
console.log('TOTAL_VIOLATIONS:', violations);
|
||||
if (violations === 0) {
|
||||
console.log('VERIFICATION_PASSED: 0 residual style violations across src/');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('VERIFICATION_FAILED');
|
||||
process.exit(1);
|
||||
}
|
||||
144
scripts/verify_cookies.mjs
Normal file
144
scripts/verify_cookies.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verification that every auth cookie the app issues carries the hardened
|
||||
* attributes: HttpOnly + SameSite=Lax (+ Secure in production).
|
||||
*
|
||||
* Runs under plain Node — no build step:
|
||||
* node scripts/verify_cookies.mjs
|
||||
*
|
||||
* Three layers:
|
||||
* 1. SOURCE assertion on the single source of truth — `cookieSecurityOptions()`
|
||||
* in src/lib/auth/config.ts must declare httpOnly / sameSite / secure /
|
||||
* path. Every cookie writer below is checked only for *delegating* to
|
||||
* this helper (or to sessionCookieOptions, which itself delegates to
|
||||
* it), not for re-declaring the attributes inline — the attributes only
|
||||
* live in one place, so re-checking them at each call site just goes
|
||||
* stale the next time a call site is refactored to delegate instead of
|
||||
* inlining.
|
||||
* 2. DELEGATION assertions — session.ts, guest.ts and the Google OAuth
|
||||
* handshake/callback routes must each go through the shared helper, and
|
||||
* no cookie writer in the auth stack may set attributes without it.
|
||||
* 3. SERIALIZATION assertion — feed the exact attribute values the helper
|
||||
* produces through Next's cookie serializer (NextResponse) and confirm
|
||||
* the resulting Set-Cookie string contains `HttpOnly` and `SameSite=Lax`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function check(name, condition, detail = "") {
|
||||
checks += 1;
|
||||
if (condition) {
|
||||
console.log(` PASS ${name}`);
|
||||
} else {
|
||||
failures += 1;
|
||||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const read = (rel) => readFileSync(join(root, rel), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Source-level attribute assertion on the single shared helper
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n[1] Core helper — src/lib/auth/config.ts (cookieSecurityOptions)");
|
||||
{
|
||||
const src = read("src/lib/auth/config.ts");
|
||||
const fn = src.slice(src.indexOf("export function cookieSecurityOptions"));
|
||||
check("cookieSecurityOptions declares httpOnly: true", /\bhttpOnly:\s*true\b/.test(fn));
|
||||
check("cookieSecurityOptions declares sameSite: \"lax\"", /sameSite:\s*"lax"/.test(fn));
|
||||
check("cookieSecurityOptions declares secure: isProduction", /\bsecure:\s*isProduction\b/.test(fn));
|
||||
check("cookieSecurityOptions declares path: \"/\"", /path:\s*"\/"/.test(fn));
|
||||
}
|
||||
|
||||
console.log("\n[2] Session cookie — src/lib/auth/session.ts (sessionCookieOptions)");
|
||||
{
|
||||
const src = read("src/lib/auth/session.ts");
|
||||
check("sessionCookieOptions delegates to cookieSecurityOptions", /export function sessionCookieOptions[\s\S]*?cookieSecurityOptions\(/.test(src));
|
||||
check("session cookie is set through sessionCookieOptions (single source)", /cookieStore\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src));
|
||||
check("no other raw session-cookie set without the helper", (src.match(/cookieStore\.set\(/g) || []).length === 1);
|
||||
}
|
||||
|
||||
console.log("\n[3] Guest cookie — src/lib/auth/guest.ts (applyGuestCookie)");
|
||||
{
|
||||
const src = read("src/lib/auth/guest.ts");
|
||||
const block = src.slice(src.indexOf("response.cookies.set"));
|
||||
check("guest cookie is set via cookieSecurityOptions", /response\.cookies\.set\(\s*GUEST_COOKIE,\s*ctx\.cookieValue,\s*cookieSecurityOptions\(/.test(block));
|
||||
}
|
||||
|
||||
console.log("\n[4] OAuth handshake cookies — src/app/api/auth/google/route.ts");
|
||||
{
|
||||
const src = read("src/app/api/auth/google/route.ts");
|
||||
check("oauth state/verifier cookies are set via cookieSecurityOptions", /cookieSecurityOptions\(\{[^}]*\}\)/.test(src));
|
||||
check("both handshake cookies reuse the same options object", (src.match(/response\.cookies\.set\((OAUTH_STATE_COOKIE|OAUTH_VERIFIER_COOKIE),\s*\w+,\s*cookieOptions\)/g) || []).length === 2);
|
||||
}
|
||||
|
||||
console.log("\n[5] OAuth session cookie — src/app/api/auth/google/callback/route.ts");
|
||||
{
|
||||
const src = read("src/app/api/auth/google/callback/route.ts");
|
||||
// The callback sets the session cookie through the same shared helper, so the
|
||||
// attribute source is config.ts (asserted above). Confirm the delegation.
|
||||
check(
|
||||
"callback sets the session cookie via sessionCookieOptions",
|
||||
/response\.cookies\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src)
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n[6] No cookie writer in the auth stack bypasses the shared helper");
|
||||
{
|
||||
// Any `.cookies.set(` in the auth stack must be immediately followed by a
|
||||
// call to cookieSecurityOptions(...) or sessionCookieOptions(...) as its
|
||||
// options argument — never an inline object literal.
|
||||
const files = [
|
||||
"src/lib/auth/session.ts",
|
||||
"src/lib/auth/guest.ts",
|
||||
"src/app/api/auth/google/route.ts",
|
||||
"src/app/api/auth/google/callback/route.ts",
|
||||
];
|
||||
for (const rel of files) {
|
||||
const src = read(rel);
|
||||
const setCalls = src.match(/\.cookies\.set\([^;]*?\)(?=;|\n)/gs) || [];
|
||||
const bypassing = setCalls.filter((c) => !/(cookieSecurityOptions|sessionCookieOptions|cookieOptions)\s*\(/.test(c) && !c.includes("cookieOptions)"));
|
||||
check(`${rel}: every .cookies.set() options arg comes from the shared helper`, bypassing.length === 0, bypassing.join(" | "));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Serialization assertion (NextResponse + the exact attribute values)
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n[7] Serialized Set-Cookie contains HttpOnly and SameSite=Lax");
|
||||
{
|
||||
// Next ships its server entry as CJS; createRequire gives us the same
|
||||
// resolution plain `require('next/server')` uses.
|
||||
const { createRequire } = await import("node:module");
|
||||
const require = createRequire(import.meta.url);
|
||||
const { NextResponse } = require("next/server");
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set("sr_session", "test-token", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: false, // dev — production flips via isProduction
|
||||
path: "/",
|
||||
expires: new Date(Date.now() + 3_600_000),
|
||||
});
|
||||
const header = res.headers.get("set-cookie") || "";
|
||||
check("Set-Cookie header present", header.length > 0);
|
||||
check("contains HttpOnly", /\bHttpOnly\b/i.test(header), header);
|
||||
check("contains SameSite (case-insensitive)", /SameSite=Lax/i.test(header), header);
|
||||
check("contains Path=/", /Path=\//i.test(header), header);
|
||||
check("session cookie is not visible to JS", !/\bHttpOnly=false\b/i.test(header));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(`\n${checks} checks, ${failures} failure(s)`);
|
||||
if (failures > 0) {
|
||||
console.error("COOKIE VERIFICATION FAILED");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("COOKIE VERIFICATION PASSED");
|
||||
345
scripts/verify_cors.mjs
Normal file
345
scripts/verify_cors.mjs
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* CORS policy verification harness.
|
||||
*
|
||||
* Exercises the pure helpers in src/lib/http/cors.ts with simulated requests
|
||||
* (real NextRequest/NextResponse instances) and asserts the policy:
|
||||
* - preflight from an allowed origin -> 204 + correct headers
|
||||
* - preflight from a disallowed origin -> 403
|
||||
* - normal request, disallowed origin -> 403
|
||||
* - no Origin header / allowed origin -> undefined (continue)
|
||||
* - allowlist normalization (trailing slash, case, default port)
|
||||
* - never emits `Access-Control-Allow-Origin: *`
|
||||
*
|
||||
* Run: node scripts/verify_cors.mjs
|
||||
* (Node >= 23.6 / 24 runs the imported .ts directly via built-in type stripping;
|
||||
* no tsx required.)
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
getAllowedOrigins,
|
||||
isOriginAllowed,
|
||||
corsHeadersFor,
|
||||
handleCors,
|
||||
originComparisonKey,
|
||||
CORS_ALLOWED_METHODS,
|
||||
CORS_ERROR_CODE,
|
||||
} from "../src/lib/http/cors.ts";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(String(err instanceof Error ? err.stack : err).replace(/^/gm, " "));
|
||||
}
|
||||
}
|
||||
|
||||
/** Env that looks like production: app origin + a few extra allowlisted origins. */
|
||||
const PROD_ENV = {
|
||||
CORS_ORIGINS: "https://admin.example.com, http://localhost:8080/",
|
||||
NEXT_PUBLIC_APP_URL: "https://app.example.com",
|
||||
};
|
||||
|
||||
const APP_ORIGIN = "https://app.example.com";
|
||||
|
||||
function req(method, headers, url = "https://app.example.com/api/receipts") {
|
||||
return new NextRequest(url, { method, headers });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("CORS policy verification\n");
|
||||
console.log("1) Allowlist construction & normalization");
|
||||
|
||||
check("getAllowedOrigins returns full origins (echo form), scheme & case preserved", () => {
|
||||
const list = getAllowedOrigins(PROD_ENV);
|
||||
assert.ok(list.includes("https://app.example.com"), `NEXT_PUBLIC_APP_URL missing: ${list}`);
|
||||
assert.ok(list.includes("https://admin.example.com"), `missing admin origin: ${list}`);
|
||||
assert.ok(list.includes("http://localhost:8080"), `missing localhost origin: ${list}`);
|
||||
// Trailing slashes are stripped during normalization (spec).
|
||||
assert.ok(!list.some((o) => o.endsWith("/")), `trailing slash survived: ${list}`);
|
||||
});
|
||||
|
||||
check("allowlist is de-duplicated by comparison key; app origin wins over duplicates", () => {
|
||||
const dupEnv = {
|
||||
CORS_ORIGINS: "https://app.example.com, https://APP.EXAMPLE.COM:443",
|
||||
NEXT_PUBLIC_APP_URL: "https://app.example.com",
|
||||
};
|
||||
// All three spellings share the key "app.example.com" -> exactly one entry,
|
||||
// and it is the canonical NEXT_PUBLIC_APP_URL spelling (added first).
|
||||
const list = getAllowedOrigins(dupEnv);
|
||||
assert.deepEqual(list, ["https://app.example.com"], `expected single canonical entry: ${list}`);
|
||||
const keys = getAllowedOrigins(PROD_ENV).map(originComparisonKey);
|
||||
assert.equal(new Set(keys).size, keys.length, `duplicate keys: ${keys}`);
|
||||
});
|
||||
|
||||
check("comparison key strips scheme, trailing slash, lowercases host, collapses default port", () => {
|
||||
assert.equal(originComparisonKey("https://App.Example.com/"), "app.example.com");
|
||||
assert.equal(originComparisonKey("https://app.example.com:443"), "app.example.com");
|
||||
assert.equal(originComparisonKey("http://app.example.com:80"), "app.example.com");
|
||||
assert.equal(originComparisonKey("HTTPS://app.example.com:3000/"), "app.example.com:3000");
|
||||
assert.equal(originComparisonKey("http://localhost:8080"), "localhost:8080");
|
||||
});
|
||||
|
||||
check("comparison key drops path/query/fragment defensively", () => {
|
||||
assert.equal(
|
||||
originComparisonKey("https://app.example.com/some/path?x=1#frag"),
|
||||
"app.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
check("comparison key handles IPv6 literals", () => {
|
||||
assert.equal(originComparisonKey("http://[::1]:3000"), "[::1]:3000");
|
||||
assert.equal(originComparisonKey("http://[::1]"), "[::1]");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n2) isOriginAllowed");
|
||||
|
||||
check("no Origin header (null/empty) is allowed (curl, same-origin, server-to-server)", () => {
|
||||
assert.equal(isOriginAllowed(null, PROD_ENV), true);
|
||||
assert.equal(isOriginAllowed("", PROD_ENV), true);
|
||||
});
|
||||
|
||||
check("exact allowed origin matches", () => {
|
||||
assert.equal(isOriginAllowed(APP_ORIGIN, PROD_ENV), true);
|
||||
assert.equal(isOriginAllowed("https://admin.example.com", PROD_ENV), true);
|
||||
assert.equal(isOriginAllowed("http://localhost:8080", PROD_ENV), true);
|
||||
});
|
||||
|
||||
check("normalization: trailing slash, mixed case, explicit default port all match", () => {
|
||||
assert.equal(isOriginAllowed("https://app.example.com/", PROD_ENV), true);
|
||||
assert.equal(isOriginAllowed("HTTPS://APP.EXAMPLE.COM", PROD_ENV), true);
|
||||
assert.equal(isOriginAllowed("https://app.example.com:443", PROD_ENV), true);
|
||||
});
|
||||
|
||||
check("disallowed origins are rejected (never *, exact match only)", () => {
|
||||
assert.equal(isOriginAllowed("https://evil.example.com", PROD_ENV), false);
|
||||
assert.equal(isOriginAllowed("https://example.com", PROD_ENV), false);
|
||||
assert.equal(isOriginAllowed("http://localhost:9999", PROD_ENV), false);
|
||||
assert.equal(isOriginAllowed("null", PROD_ENV), false);
|
||||
});
|
||||
|
||||
check("substring / suffix attacks do not match", () => {
|
||||
assert.equal(isOriginAllowed("https://app.example.com.evil.com", PROD_ENV), false);
|
||||
assert.equal(isOriginAllowed("https://app.example.com.evil.com:443", PROD_ENV), false);
|
||||
assert.equal(isOriginAllowed("https://evilexample.com", PROD_ENV), false);
|
||||
assert.equal(isOriginAllowed("https://admin.example.com.evil.com", PROD_ENV), false);
|
||||
});
|
||||
|
||||
check("host + port are exact; scheme is ignored for comparison (documented policy)", () => {
|
||||
// Port is part of the exact match.
|
||||
assert.equal(isOriginAllowed("https://app.example.com:444", PROD_ENV), false);
|
||||
assert.equal(isOriginAllowed("http://localhost:8081", PROD_ENV), false);
|
||||
// The http/https prefix is stripped for comparison (policy), so the same
|
||||
// host:port under the other scheme is treated as the same origin.
|
||||
assert.equal(isOriginAllowed("http://app.example.com", PROD_ENV), true);
|
||||
});
|
||||
|
||||
check("empty allowlist => same-origin only (everything with an Origin rejected)", () => {
|
||||
const empty = { CORS_ORIGINS: "", NEXT_PUBLIC_APP_URL: "" };
|
||||
assert.equal(isOriginAllowed(null, empty), true);
|
||||
assert.equal(isOriginAllowed("https://app.example.com", empty), false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n3) corsHeadersFor");
|
||||
|
||||
check("allowed origin -> exact echo + Vary: Origin + credentials; never '*'", () => {
|
||||
const h = corsHeadersFor(APP_ORIGIN, PROD_ENV);
|
||||
assert.equal(h["Access-Control-Allow-Origin"], APP_ORIGIN);
|
||||
assert.equal(h["Vary"], "Origin");
|
||||
assert.equal(h["Access-Control-Allow-Credentials"], "true");
|
||||
assert.ok(!Object.values(h).includes("*"), "wildcard leaked into headers");
|
||||
});
|
||||
|
||||
check("disallowed / missing origin -> no CORS headers", () => {
|
||||
assert.deepEqual(corsHeadersFor("https://evil.example.com", PROD_ENV), {});
|
||||
assert.deepEqual(corsHeadersFor(null, PROD_ENV), {});
|
||||
assert.deepEqual(corsHeadersFor("", PROD_ENV), {});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n4) handleCors — preflight");
|
||||
|
||||
check("preflight from allowed origin -> 204 with full header set", () => {
|
||||
const res = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin: APP_ORIGIN,
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type, authorization",
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
assert.ok(res, "expected a response");
|
||||
assert.equal(res.status, 204);
|
||||
assert.equal(res.headers.get("access-control-allow-origin"), APP_ORIGIN);
|
||||
assert.equal(res.headers.get("vary"), "Origin");
|
||||
assert.equal(res.headers.get("access-control-allow-credentials"), "true");
|
||||
assert.equal(res.headers.get("access-control-allow-methods"), CORS_ALLOWED_METHODS);
|
||||
assert.equal(res.headers.get("access-control-max-age"), "600");
|
||||
assert.equal(res.headers.get("access-control-allow-headers"), "content-type, authorization");
|
||||
});
|
||||
|
||||
check("preflight echo uses the exact request origin, never a wildcard", () => {
|
||||
const res = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin: "https://admin.example.com",
|
||||
"access-control-request-method": "GET",
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 204);
|
||||
const acao = res.headers.get("access-control-allow-origin");
|
||||
assert.equal(acao, "https://admin.example.com");
|
||||
assert.ok(acao && !acao.includes("*"), "Access-Control-Allow-Origin contains '*'");
|
||||
});
|
||||
|
||||
check("preflight from disallowed origin -> 403, no ACAO header", () => {
|
||||
const res = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin: "https://evil.example.com",
|
||||
"access-control-request-method": "POST",
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal(res.headers.get("access-control-allow-origin"), null, "no ACAO on refusal");
|
||||
});
|
||||
|
||||
check("preflight 403 carries the machine-readable error code", async () => {
|
||||
const res = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin: "https://evil.example.com",
|
||||
"access-control-request-method": "POST",
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
assert.ok(res);
|
||||
const body = await res.json();
|
||||
assert.deepEqual(body, { error: CORS_ERROR_CODE });
|
||||
});
|
||||
|
||||
check("Access-Control-Allow-Headers echo is validated (invalid tokens dropped)", () => {
|
||||
const res = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin: APP_ORIGIN,
|
||||
"access-control-request-method": "PUT",
|
||||
"access-control-request-headers": "content-type, bad header!, x-foo",
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
assert.ok(res);
|
||||
assert.equal(res.headers.get("access-control-allow-headers"), "content-type, x-foo");
|
||||
});
|
||||
|
||||
check("Access-Control-Allow-Headers echo is bounded (oversized input omitted)", () => {
|
||||
const res = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin: APP_ORIGIN,
|
||||
"access-control-request-method": "PUT",
|
||||
"access-control-request-headers": "x-big-" + "a".repeat(5000),
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
assert.ok(res);
|
||||
assert.equal(res.headers.get("access-control-allow-headers"), null);
|
||||
});
|
||||
|
||||
check("Access-Control-Allow-Headers echo is de-duplicated", () => {
|
||||
const res = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin: APP_ORIGIN,
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type, content-type, authorization",
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
assert.ok(res);
|
||||
assert.equal(res.headers.get("access-control-allow-headers"), "content-type, authorization");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n5) handleCors — non-preflight");
|
||||
|
||||
check("normal request from allowed origin -> undefined (continue)", () => {
|
||||
const res = handleCors(req("GET", { origin: APP_ORIGIN }), PROD_ENV);
|
||||
assert.equal(res, undefined);
|
||||
});
|
||||
|
||||
check("normal request with no Origin -> undefined (continue)", () => {
|
||||
const res = handleCors(req("GET", {}), PROD_ENV);
|
||||
assert.equal(res, undefined);
|
||||
});
|
||||
|
||||
check("normal request from disallowed origin -> 403", () => {
|
||||
const res = handleCors(req("GET", { origin: "https://evil.example.com" }), PROD_ENV);
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
check("bare OPTIONS without Access-Control-Request-Method is not a preflight", () => {
|
||||
// Disallowed origin on a bare OPTIONS is still refused (cross-origin).
|
||||
const disallowed = handleCors(req("OPTIONS", { origin: "https://evil.example.com" }), PROD_ENV);
|
||||
assert.ok(disallowed);
|
||||
assert.equal(disallowed.status, 403);
|
||||
// Allowed origin on a bare OPTIONS passes through.
|
||||
const allowed = handleCors(req("OPTIONS", { origin: APP_ORIGIN }), PROD_ENV);
|
||||
assert.equal(allowed, undefined);
|
||||
});
|
||||
|
||||
check("POST (credentials path) from allowed origin -> undefined", () => {
|
||||
const res = handleCors(req("POST", { origin: APP_ORIGIN }), PROD_ENV);
|
||||
assert.equal(res, undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n6) global: no '*' is ever emitted as a header value");
|
||||
|
||||
function collectHeaderValues() {
|
||||
const values = [];
|
||||
const origins = ["https://app.example.com", "https://admin.example.com", "http://localhost:8080"];
|
||||
for (const origin of origins) {
|
||||
const preflight = handleCors(
|
||||
req("OPTIONS", {
|
||||
origin,
|
||||
"access-control-request-method": "GET",
|
||||
"access-control-request-headers": "content-type, authorization",
|
||||
}),
|
||||
PROD_ENV
|
||||
);
|
||||
if (preflight) preflight.headers.forEach((v) => values.push(v));
|
||||
values.push(...Object.values(corsHeadersFor(origin, PROD_ENV)));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
check("no emitted header value equals or contains '*'", () => {
|
||||
const values = collectHeaderValues();
|
||||
assert.ok(values.length > 0, "expected to collect some header values");
|
||||
for (const v of values) {
|
||||
assert.ok(!v.includes("*"), `wildcard value emitted: ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
check("allowlist itself contains no wildcard entries", () => {
|
||||
for (const origin of getAllowedOrigins(PROD_ENV)) {
|
||||
assert.ok(!origin.includes("*"), `wildcard in allowlist: ${origin}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) {
|
||||
console.error("CORS verification FAILED");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("CORS verification PASSED");
|
||||
144
scripts/verify_rate_limit.mjs
Normal file
144
scripts/verify_rate_limit.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verification for the canonical rate limiter at src/lib/security/rateLimit.ts.
|
||||
*
|
||||
* Runs under plain Node (>= 22.6, type-stripping) — no tsx, no build step:
|
||||
* node scripts/verify_rate_limit.mjs
|
||||
*
|
||||
* Covers the three behaviours the security gate cares about:
|
||||
* 1. A burst over the limit is denied with a positive retryAfter (the routes
|
||||
* map that to HTTP 429 + Retry-After — see the audit doc).
|
||||
* 2. Once the window expires, the same key is allowed again.
|
||||
* 3. Different keys are independent buckets.
|
||||
* Plus the clientIp header policy (last valid XFF entry wins; spoofed first
|
||||
* entries ignored; x-real-ip fallback; unknown fallback).
|
||||
*/
|
||||
|
||||
import { rateLimit, clientIp, resetRateLimits } from "../src/lib/security/rateLimit.ts";
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function check(name, condition, detail = "") {
|
||||
checks += 1;
|
||||
if (condition) {
|
||||
console.log(` PASS ${name}`);
|
||||
} else {
|
||||
failures += 1;
|
||||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Burst over the limit
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n[1] Burst over the limit → denied with retryAfter > 0");
|
||||
{
|
||||
resetRateLimits();
|
||||
const LIMIT = 3;
|
||||
const WINDOW_MS = 60_000;
|
||||
const key = "login:ip:203.0.113.9";
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < LIMIT + 2; i++) results.push(rateLimit(key, LIMIT, WINDOW_MS));
|
||||
|
||||
const allowedCount = results.filter((r) => r.allowed).length;
|
||||
const blocked = results[LIMIT]; // first denial
|
||||
check(
|
||||
"exactly `limit` requests allowed before denial",
|
||||
allowedCount === LIMIT,
|
||||
`allowed ${allowedCount}, expected ${LIMIT}`
|
||||
);
|
||||
check(
|
||||
"first over-limit request is denied",
|
||||
blocked && blocked.allowed === false,
|
||||
JSON.stringify(blocked)
|
||||
);
|
||||
check(
|
||||
"denied request reports a positive retryAfter (→ Retry-After header)",
|
||||
blocked && blocked.retryAfter > 0,
|
||||
`retryAfter=${blocked?.retryAfter}`
|
||||
);
|
||||
check(
|
||||
"every further request stays denied inside the window",
|
||||
results.slice(LIMIT).every((r) => !r.allowed)
|
||||
);
|
||||
check(
|
||||
"allowed requests report retryAfter 0",
|
||||
results.slice(0, LIMIT).every((r) => r.retryAfter === 0)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Window expiry → allowed again
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n[2] Window expiry resets the bucket");
|
||||
{
|
||||
resetRateLimits();
|
||||
const key = "signup:ip:198.51.100.4";
|
||||
const WINDOW_MS = 120; // short window so the test does not stall
|
||||
for (let i = 0; i < 2; i++) rateLimit(key, 1, WINDOW_MS);
|
||||
const blockedNow = rateLimit(key, 1, WINDOW_MS);
|
||||
check("denied while inside the window", blockedNow.allowed === false);
|
||||
|
||||
await sleep(WINDOW_MS + 30);
|
||||
const allowedAfter = rateLimit(key, 1, WINDOW_MS);
|
||||
check("allowed again after the window rolls over", allowedAfter.allowed === true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Different keys do not interfere
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n[3] Keys are isolated buckets");
|
||||
{
|
||||
resetRateLimits();
|
||||
const keyA = "reset:ip:192.0.2.1";
|
||||
const keyB = "reset:ip:192.0.2.2";
|
||||
rateLimit(keyA, 1, 60_000);
|
||||
rateLimit(keyA, 1, 60_000); // exhaust A
|
||||
check("key A is denied after its own limit", rateLimit(keyA, 1, 60_000).allowed === false);
|
||||
check("key B is still allowed", rateLimit(keyB, 1, 60_000).allowed === true);
|
||||
check(
|
||||
"route prefix keeps routes apart",
|
||||
rateLimit("login:ip:192.0.2.1", 1, 60_000).allowed === true
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. clientIp header policy
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log("\n[4] clientIp trusts the LAST valid XFF entry, never the first");
|
||||
{
|
||||
const req = (headers) => new Request("http://localhost", { headers });
|
||||
|
||||
check(
|
||||
"takes last valid IP when the client spoofs leading entries",
|
||||
clientIp(req({ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 198.51.100.9" })) ===
|
||||
"198.51.100.9",
|
||||
clientIp(req({ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 198.51.100.9" }))
|
||||
);
|
||||
check(
|
||||
"ignores a spoofed-only chain (no valid IP) and falls back to x-real-ip",
|
||||
clientIp(req({ "x-forwarded-for": "evil, not-an-ip", "x-real-ip": "10.0.0.5" })) ===
|
||||
"10.0.0.5"
|
||||
);
|
||||
check(
|
||||
"falls back to x-real-ip when XFF is absent",
|
||||
clientIp(req({ "x-real-ip": "10.0.0.6" })) === "10.0.0.6"
|
||||
);
|
||||
check(
|
||||
"never returns a spoofed string — 'unknown' when nothing validates",
|
||||
clientIp(req({ "x-forwarded-for": "spoofed-value" })) === "unknown"
|
||||
);
|
||||
check("no headers at all → 'unknown'", clientIp(req({})) === "unknown");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(`\n${checks} checks, ${failures} failure(s)`);
|
||||
if (failures > 0) {
|
||||
console.error("RATE-LIMIT VERIFICATION FAILED");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("RATE-LIMIT VERIFICATION PASSED");
|
||||
510
scripts/verify_sanitize.mjs
Normal file
510
scripts/verify_sanitize.mjs
Normal file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verification for the "Sanitize before storing" hardening:
|
||||
* - src/lib/ingest/sanitize.ts (string/number sanitizers + stored-receipt zod schema)
|
||||
* - src/lib/export/csvGenerator.ts (CSV formula-injection neutralizer)
|
||||
* - excel / pdf generators (values stay plain strings / literal text)
|
||||
*
|
||||
* Runs under plain Node ≥ 23.6 (native TypeScript type-stripping):
|
||||
* node scripts/verify_sanitize.mjs
|
||||
*
|
||||
* Exit code 0 = all assertions passed, 1 = at least one failed.
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
import {
|
||||
SANITIZE_LIMITS,
|
||||
sanitizeText,
|
||||
sanitizeMultilineText,
|
||||
sanitizeUrl,
|
||||
sanitizeCurrency,
|
||||
sanitizeReceiptBatch,
|
||||
sanitizeReceipt,
|
||||
StoredReceiptSchema,
|
||||
} from "../src/lib/ingest/sanitize.ts";
|
||||
import {
|
||||
generateAccountingCsv,
|
||||
neutralizeFormulaPrefix,
|
||||
} from "../src/lib/export/csvGenerator.ts";
|
||||
import { generateDualSheetExcel } from "../src/lib/export/excelGenerator.ts";
|
||||
import { generateReceiptPdf } from "../src/lib/export/pdfGenerator.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiny test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` PASS ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
failures.push({ name, err });
|
||||
console.error(` FAIL ${name}\n ${err && err.message ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validReceipt(overrides = {}) {
|
||||
return {
|
||||
id: "rcpt_test_123",
|
||||
imageHash: "abc123def456",
|
||||
merchant: {
|
||||
name: "REWE",
|
||||
address: "Hauptstr 1, 10115 Berlin",
|
||||
taxId: "DE123456789",
|
||||
confidence: 0.98,
|
||||
},
|
||||
date: { isoDate: "2026-08-15", time: "10:30", confidence: 0.97 },
|
||||
documentType: "KASSENBON",
|
||||
receiptNumber: "2026-0815-001",
|
||||
currency: "EUR",
|
||||
totalAmount: { value: 12.34, confidence: 0.99 },
|
||||
netAmount: 10.36,
|
||||
tipAmount: null,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
|
||||
lineItems: [
|
||||
{ description: "Apfel", quantity: 2, price: 12.34, unitPrice: 6.17, taxRate: 19 },
|
||||
],
|
||||
suggestedCategory: "Sonstiges",
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
issues: null,
|
||||
userConfirmed: null,
|
||||
},
|
||||
rawText: "REWE\nApfel 2x 6,17\nSUMME 12,34",
|
||||
paymentMethod: "EC_KARTE",
|
||||
previewUrl: "https://storage.example/rcpt_test_123.jpg",
|
||||
originalFileName: "bon.jpg",
|
||||
fileSizeBytes: 12345,
|
||||
status: "ready",
|
||||
createdAt: "2026-08-15T10:31:00.000Z",
|
||||
updatedAt: "2026-08-15T10:31:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function maliciousReceipt() {
|
||||
return validReceipt({
|
||||
merchant: {
|
||||
name: "<script>alert(1)</script>",
|
||||
address: "<img src=x onerror=alert(2)>",
|
||||
taxId: "javascript:alert(3)",
|
||||
confidence: 1,
|
||||
},
|
||||
receiptNumber: '=HYPERLINK("http://evil.example","x")',
|
||||
suggestedCategory: "@SUM(1+1)",
|
||||
rawText: '<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007',
|
||||
lineItems: [
|
||||
{ description: "+cmd|' /C calc'!A0", quantity: 1, price: 12.34, unitPrice: 12.34, taxRate: 19 },
|
||||
{ description: "<svg onload=alert(5)>", quantity: 2, price: 0.5, taxRate: null },
|
||||
],
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: true,
|
||||
reviewField: "merchant",
|
||||
reviewReason: "<script>alert(6)</script>",
|
||||
issues: [{ field: "merchant", severity: "warning", message: "<img onerror=alert(7)>" }],
|
||||
userConfirmed: null,
|
||||
},
|
||||
paymentMethod: "EC_KARTE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Splits one quoted CSV line (; delimiter) into unquoted cells. */
|
||||
function splitCsvLine(line) {
|
||||
const cells = [];
|
||||
let cur = "";
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
cur += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ";") {
|
||||
cells.push(cur);
|
||||
cur = "";
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
cells.push(cur);
|
||||
return cells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. String sanitizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[1] String sanitizer");
|
||||
|
||||
test("strips script tags from merchant name", () => {
|
||||
assert.equal(sanitizeText("<script>alert(1)</script>", 200), "alert(1)");
|
||||
});
|
||||
|
||||
test("strips img/svg onerror markup entirely (tag content included)", () => {
|
||||
// The whole tag — including payload text inside it — is removed.
|
||||
assert.equal(sanitizeText('<img src=x onerror=alert(1)>', 200), "");
|
||||
assert.equal(sanitizeText("<svg onload=alert(5)>", 200), "");
|
||||
});
|
||||
|
||||
test("strips comments / doctype / CDATA / processing instructions", () => {
|
||||
assert.equal(sanitizeText("a<!-- x -->b", 200), "ab");
|
||||
assert.equal(sanitizeText("a<!DOCTYPE html>b", 200), "ab");
|
||||
assert.equal(sanitizeText("a<![CDATA[ x ]]>b", 200), "ab");
|
||||
assert.equal(sanitizeText("a<?php echo 1 ?>b", 200), "ab");
|
||||
});
|
||||
|
||||
test("malformed double-angle markup cannot survive", () => {
|
||||
assert.equal(sanitizeText("<<script>alert(1)", 200), "alert(1)");
|
||||
});
|
||||
|
||||
test("strips control chars (NUL, BEL, ESC …) and collapses whitespace", () => {
|
||||
assert.equal(sanitizeText(" Hello\u0000World\u0007!\t\t", 200), "HelloWorld!");
|
||||
assert.equal(sanitizeText("a\u0000\nb", 200), "a b");
|
||||
});
|
||||
|
||||
test("trims and caps length", () => {
|
||||
assert.equal(sanitizeText(" x ", 200), "x");
|
||||
assert.equal(sanitizeText("x".repeat(5000), 200).length, 200);
|
||||
assert.equal(sanitizeText(12345, 200), ""); // non-string → ""
|
||||
});
|
||||
|
||||
test("multiline OCR sanitizer keeps newlines, strips markup and NUL", () => {
|
||||
const out = sanitizeMultilineText("<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007", 1000);
|
||||
assert.equal(out, "Zeile 2\nZeile 3");
|
||||
assert.ok(!out.includes("<"));
|
||||
});
|
||||
|
||||
test("multiline OCR caps at 50k", () => {
|
||||
assert.equal(sanitizeMultilineText("y".repeat(100000), 50000).length, 50000);
|
||||
});
|
||||
|
||||
test("sanitizeUrl drops dangerous schemes and caps length", () => {
|
||||
assert.equal(sanitizeUrl("javascript:alert(1)"), undefined);
|
||||
assert.equal(sanitizeUrl("vbscript:msgbox(1)"), undefined);
|
||||
assert.equal(sanitizeUrl("data:text/html,<script>alert(1)</script>"), undefined);
|
||||
assert.equal(sanitizeUrl(" https://example.com/a.jpg "), "https://example.com/a.jpg");
|
||||
assert.equal(sanitizeUrl("x".repeat(5000)).length, SANITIZE_LIMITS.previewUrl);
|
||||
});
|
||||
|
||||
test("sanitizeCurrency normalizes and falls back", () => {
|
||||
assert.equal(sanitizeCurrency(" chf "), "CHF");
|
||||
assert.equal(sanitizeCurrency("EUR"), "EUR");
|
||||
assert.equal(sanitizeCurrency(""), "EUR");
|
||||
assert.equal(sanitizeCurrency(undefined), "EUR");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Formula-injection neutralizer (CSV export boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[2] CSV formula-injection neutralizer");
|
||||
|
||||
test("prefixes = + - @ tab CR payloads with a single quote", () => {
|
||||
assert.equal(neutralizeFormulaPrefix('=HYPERLINK("http://evil","x")'), "'=HYPERLINK(\"http://evil\",\"x\")");
|
||||
assert.equal(neutralizeFormulaPrefix("+cmd|' /C calc'!A0"), "'+cmd|' /C calc'!A0");
|
||||
assert.equal(neutralizeFormulaPrefix("@SUM(1+1)"), "'@SUM(1+1)");
|
||||
assert.equal(neutralizeFormulaPrefix("-cmd|'/C calc'!A0"), "'-cmd|'/C calc'!A0");
|
||||
assert.equal(neutralizeFormulaPrefix("\t=1+1"), "'\t=1+1");
|
||||
assert.equal(neutralizeFormulaPrefix("\r=1+1"), "'\r=1+1");
|
||||
});
|
||||
|
||||
test("leaves plain numbers (incl. negative credit notes) untouched", () => {
|
||||
assert.equal(neutralizeFormulaPrefix("-5,00"), "-5,00");
|
||||
assert.equal(neutralizeFormulaPrefix("+1,25"), "+1,25");
|
||||
assert.equal(neutralizeFormulaPrefix("123"), "123");
|
||||
assert.equal(neutralizeFormulaPrefix(""), "");
|
||||
assert.equal(neutralizeFormulaPrefix("'=already-safe"), "'=already-safe");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Stored-receipt schema: malicious input → sanitized safe output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[3] Stored-receipt schema (sanitize before store)");
|
||||
|
||||
test("malicious receipt is accepted and fully sanitized (no active markup)", () => {
|
||||
const result = sanitizeReceiptBatch({ receipts: [maliciousReceipt()] });
|
||||
assert.ok(result.ok, `expected ok, got: ${result.error}`);
|
||||
const r = result.receipts[0];
|
||||
|
||||
assert.equal(r.merchant.name, "alert(1)");
|
||||
assert.equal(r.merchant.address, null); // whole <img …> tag removed → empty → null
|
||||
assert.equal(r.receiptNumber, '=HYPERLINK("http://evil.example","x")'); // inert text at rest
|
||||
assert.equal(r.suggestedCategory, "@SUM(1+1)"); // inert text at rest
|
||||
assert.equal(r.rawText, "Zeile 2\nZeile 3");
|
||||
assert.equal(r.lineItems[0].description, "+cmd|' /C calc'!A0");
|
||||
assert.equal(r.lineItems[1].description, ""); // <svg …> tag removed entirely
|
||||
assert.equal(r.validation.reviewReason, "alert(6)");
|
||||
assert.equal(r.validation.issues[0].message, ""); // <img …> tag removed entirely
|
||||
assert.equal(r.validation.issues[0].severity, "warning");
|
||||
|
||||
const serialized = JSON.stringify(r);
|
||||
assert.ok(!serialized.includes("<"), "no angle bracket may survive in stored data");
|
||||
assert.ok(!serialized.includes("\u0000"), "no NUL may survive in stored data");
|
||||
});
|
||||
|
||||
test("markup inside date / receiptNumber is stripped, not rejected", () => {
|
||||
const result = sanitizeReceiptBatch(
|
||||
validReceipt({ date: { isoDate: "2026-<b>08</b>-15", time: null, confidence: 0.9 } })
|
||||
);
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.receipts[0].date.isoDate, "2026-08-15");
|
||||
});
|
||||
|
||||
test("legacy German date format is preserved (sanitized), not rejected", () => {
|
||||
const result = sanitizeReceiptBatch(
|
||||
validReceipt({ date: { isoDate: "15.08.2026", time: null, confidence: 0.9 } })
|
||||
);
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.receipts[0].date.isoDate, "15.08.2026");
|
||||
});
|
||||
|
||||
test("oversized strings are truncated per policy", () => {
|
||||
const result = sanitizeReceiptBatch(
|
||||
validReceipt({ merchant: { name: "X".repeat(5000), address: null, taxId: null, confidence: 1 } })
|
||||
);
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.receipts[0].merchant.name.length, SANITIZE_LIMITS.merchantName);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Numbers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[4] Numbers");
|
||||
|
||||
test("negative amounts are kept (credit notes are a domain case)", () => {
|
||||
const result = sanitizeReceiptBatch(
|
||||
validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40 })
|
||||
);
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.receipts[0].totalAmount.value, -50);
|
||||
assert.equal(result.receipts[0].netAmount, -40);
|
||||
});
|
||||
|
||||
test("huge amounts are clamped to ±1e10 (fits numeric(12,2))", () => {
|
||||
const result = sanitizeReceiptBatch(
|
||||
validReceipt({ totalAmount: { value: 1e12, confidence: 1 } })
|
||||
);
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.receipts[0].totalAmount.value, SANITIZE_LIMITS.maxAmountAbs);
|
||||
});
|
||||
|
||||
test("tax rate percent is clamped to 0..100", () => {
|
||||
const result = sanitizeReceiptBatch(
|
||||
validReceipt({
|
||||
taxBreakdown: [{ ratePercent: 150, taxAmount: 10, netAmount: 100 }],
|
||||
})
|
||||
);
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.receipts[0].taxBreakdown[0].ratePercent, 100);
|
||||
});
|
||||
|
||||
test("NaN / Infinity amounts are REJECTED (structurally invalid)", () => {
|
||||
const nan = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: NaN, confidence: 1 } }));
|
||||
assert.ok(!nan.ok);
|
||||
assert.ok(nan.error.includes("totalAmount.value"), nan.error);
|
||||
|
||||
const inf = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: Infinity, confidence: 1 } }));
|
||||
assert.ok(!inf.ok);
|
||||
});
|
||||
|
||||
test("non-number in a number field is rejected", () => {
|
||||
const result = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: "12.5", confidence: 1 } }));
|
||||
assert.ok(!result.ok);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Currency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[5] Currency");
|
||||
|
||||
test("3-letter ISO currency passes, lowercase is normalized", () => {
|
||||
const ok = sanitizeReceiptBatch(validReceipt({ currency: "chf" }));
|
||||
assert.ok(ok.ok);
|
||||
assert.equal(ok.receipts[0].currency, "CHF");
|
||||
});
|
||||
|
||||
test("missing currency defaults to EUR", () => {
|
||||
const r = validReceipt();
|
||||
delete r.currency;
|
||||
const ok = sanitizeReceiptBatch(r);
|
||||
assert.ok(ok.ok);
|
||||
assert.equal(ok.receipts[0].currency, "EUR");
|
||||
});
|
||||
|
||||
test("bad currency (symbol / 2-letter / number) is rejected with 400-style error", () => {
|
||||
for (const bad of ["€", "US", "euro", 123]) {
|
||||
const result = sanitizeReceiptBatch(validReceipt({ currency: bad }));
|
||||
assert.ok(!result.ok, `currency ${JSON.stringify(bad)} should be rejected`);
|
||||
assert.ok(result.error.includes("currency"), result.error);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Batch shape handling (mirrors POST /api/receipts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[6] Batch shapes & limits");
|
||||
|
||||
test("accepts array, {receipts}, {receipt} and single-receipt bodies", () => {
|
||||
const base = validReceipt();
|
||||
assert.ok(sanitizeReceiptBatch([base]).ok);
|
||||
assert.ok(sanitizeReceiptBatch({ receipts: [base] }).ok);
|
||||
assert.ok(sanitizeReceiptBatch({ receipt: base }).ok);
|
||||
assert.ok(sanitizeReceiptBatch(base).ok);
|
||||
});
|
||||
|
||||
test("rejects empty payloads and >500 receipts with the historic 400 messages", () => {
|
||||
assert.equal(sanitizeReceiptBatch({}).error, "No receipts provided in payload.");
|
||||
assert.equal(sanitizeReceiptBatch([]).error, "No receipts provided in payload.");
|
||||
assert.equal(sanitizeReceiptBatch(Array(501).fill(validReceipt())).error, "Too many receipts in payload.");
|
||||
});
|
||||
|
||||
test("rejects structurally invalid receipts with a clear indexed error", () => {
|
||||
const broken = validReceipt({ merchant: undefined });
|
||||
const result = sanitizeReceiptBatch({ receipts: [validReceipt(), broken] });
|
||||
assert.ok(!result.ok);
|
||||
assert.ok(result.error.includes("index 1"), result.error);
|
||||
assert.ok(result.error.includes("merchant"), result.error);
|
||||
});
|
||||
|
||||
test("rejects missing / oversized receipt ids", () => {
|
||||
assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "" })).ok);
|
||||
assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "x".repeat(100) })).ok);
|
||||
});
|
||||
|
||||
test("sanitizeReceipt single variant returns null on failure, sanitized data on success", () => {
|
||||
assert.equal(sanitizeReceipt(validReceipt({ currency: "€" })), null);
|
||||
const r = sanitizeReceipt(validReceipt({ merchant: { name: "<b>REWE</b>", address: null, taxId: null, confidence: 1 } }));
|
||||
assert.ok(r !== null);
|
||||
assert.equal(r.merchant.name, "REWE");
|
||||
});
|
||||
|
||||
test("StoredReceiptSchema keeps passthrough fields (boundingBoxes etc.)", () => {
|
||||
const r = validReceipt({ boundingBoxes: { merchant: { x: 1, y: 2, width: 3, height: 4 } } });
|
||||
const parsed = StoredReceiptSchema.safeParse(r);
|
||||
assert.ok(parsed.success);
|
||||
assert.deepEqual(parsed.data.boundingBoxes, r.boundingBoxes);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. CSV generator: no cell may start with a formula character
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[7] CSV generator output");
|
||||
|
||||
function evilCsvReceipt() {
|
||||
return validReceipt({
|
||||
merchant: { name: '=HYPERLINK("http://evil.example","x")', address: null, taxId: null, confidence: 1 },
|
||||
receiptNumber: "+cmd|' /C calc'!A0",
|
||||
suggestedCategory: "@SUM(1+1)",
|
||||
rawText: "<img src=x onerror=alert(1)>\n<b>bold</b>",
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
|
||||
});
|
||||
}
|
||||
|
||||
test("malicious merchant/receiptNumber/category cells are neutralized in CSV", () => {
|
||||
const csv = generateAccountingCsv([evilCsvReceipt()], { locale: "de" });
|
||||
|
||||
assert.ok(!csv.includes('"=HYPERLINK'), "raw formula must not appear quoted");
|
||||
assert.ok(csv.includes("'=HYPERLINK"), "neutralized cell must be prefixed with a quote");
|
||||
assert.ok(csv.includes("'+cmd|"), "DDE payload must be neutralized");
|
||||
|
||||
const lines = csv.replace(/^\uFEFF/, "").split("\r\n").filter((l) => l.length > 0);
|
||||
assert.ok(lines.length >= 2, "header + at least one data row");
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
for (const cell of splitCsvLine(lines[i])) {
|
||||
assert.ok(
|
||||
!/^[=+\-@\t\r]/.test(cell),
|
||||
`CSV cell on line ${i + 1} starts with a formula char: ${JSON.stringify(cell)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("negative credit-note amounts still export as plain numbers", () => {
|
||||
const csv = generateAccountingCsv(
|
||||
[validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40, currency: "EUR" })],
|
||||
{ locale: "de" }
|
||||
);
|
||||
assert.ok(csv.includes('"-50,00"'), "negative amount stays a number cell");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Excel generator: user values are written as plain strings, never formulas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[8] Excel generator round-trip");
|
||||
|
||||
test("merchant '=HYPERLINK(...)' is written as a plain STRING cell, not a formula", async () => {
|
||||
const buffer = await generateDualSheetExcel([evilCsvReceipt()], { locale: "de" });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.load(buffer);
|
||||
|
||||
const sheet = wb.getWorksheet("Belegübersicht");
|
||||
assert.ok(sheet, "overview sheet exists");
|
||||
const merchantCell = sheet.getCell(2, 3); // col C = merchant
|
||||
assert.equal(typeof merchantCell.value, "string");
|
||||
assert.equal(merchantCell.value, '=HYPERLINK("http://evil.example","x")');
|
||||
assert.equal(merchantCell.formula, undefined, "cell must not carry a formula");
|
||||
|
||||
const itemsSheet = wb.getWorksheet("Einzelpositionen Detail");
|
||||
assert.ok(itemsSheet, "line-items sheet exists");
|
||||
const descCell = itemsSheet.getCell(2, 4); // col D = description
|
||||
assert.equal(typeof descCell.value, "string");
|
||||
assert.equal(descCell.formula, undefined, "description cell must not carry a formula");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. PDF generator: renders literal text, no formula/HTML interpretation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n[9] PDF generator");
|
||||
|
||||
test("PDF with malicious strings still generates a valid PDF document", async () => {
|
||||
const pdf = await generateReceiptPdf([evilCsvReceipt()], { locale: "de" });
|
||||
assert.ok(pdf instanceof Uint8Array && pdf.length > 100);
|
||||
const header = Buffer.from(pdf.slice(0, 5)).toString("latin1");
|
||||
assert.equal(header, "%PDF-");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) {
|
||||
console.error("\nFailed tests:");
|
||||
for (const f of failures) console.error(` - ${f.name}: ${f.err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
95
scripts/verify_sanitize_orchestrator.mjs
Normal file
95
scripts/verify_sanitize_orchestrator.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Orchestrator-level verification for Task 1 (Sanitize before storing).
|
||||
* Tests the real implementation in src/lib/ingest/sanitize.ts (zod schemas +
|
||||
* sanitizers) and the CSV formula-injection guard in
|
||||
* src/lib/export/csvGenerator.ts (neutralizeFormulaPrefix).
|
||||
*
|
||||
* Run: node --experimental-loader ./scripts/cors-resolve-hook.mjs scripts/verify_sanitize_orchestrator.mjs
|
||||
*/
|
||||
import { sanitizeText, sanitizeMultilineText, sanitizeReceiptBatch, sanitizeReceipt, StoredReceiptSchema } from "../src/lib/ingest/sanitize.ts";
|
||||
import { neutralizeFormulaPrefix } from "../src/lib/export/csvGenerator.ts";
|
||||
|
||||
let failed = 0;
|
||||
let checks = 0;
|
||||
function expect(cond, label) {
|
||||
checks++;
|
||||
if (!cond) { failed++; console.error(" FAIL:", label); }
|
||||
else console.log(" ok ", label);
|
||||
}
|
||||
|
||||
console.log("[1] sanitizeText strips markup + control chars");
|
||||
const dirty = `<script>alert(1)</script>Händler <b>GmbH</b>\u0000\u0007`;
|
||||
const clean = sanitizeText(dirty, 200);
|
||||
expect(!clean.includes("<") && !clean.includes(">"), "no < > remain");
|
||||
expect(!clean.includes("\u0000") && !clean.includes("\u0007"), "control chars stripped");
|
||||
expect(clean.includes("Händler GmbH"), "text preserved");
|
||||
expect(sanitizeText("a\u00a0b c", 100) === "a b c", "whitespace collapsed");
|
||||
|
||||
console.log("[2] multiline OCR keeps line layout, strips markup");
|
||||
const ocr = "Zeile1\nZeile2<img onerror=alert(1)>\tZeile3";
|
||||
const ocrClean = sanitizeMultilineText(ocr, 50_000);
|
||||
expect(ocrClean.includes("\n"), "newlines survive");
|
||||
expect(!ocrClean.includes("<"), "markup stripped");
|
||||
expect(ocrClean.includes("Zeile3"), "tab survived");
|
||||
|
||||
console.log("[3] length caps");
|
||||
expect(sanitizeText("x".repeat(500), 64).length === 64, "capped at limit");
|
||||
|
||||
console.log("[4] sanitizeReceiptBatch — malicious receipt payload");
|
||||
const evil = {
|
||||
id: "r1<script>",
|
||||
merchant: { name: '=HYPERLINK("http://evil","x")', address: null, taxId: null, confidence: 1 },
|
||||
date: { isoDate: "2026-08-15", time: null, confidence: 1 },
|
||||
documentType: "KASSENBON",
|
||||
receiptNumber: "+cmd|' /C calc'!A0",
|
||||
currency: "EUR",
|
||||
totalAmount: { value: 12.5, confidence: 1 },
|
||||
netAmount: 10.5,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 2, netAmount: 10.5 }],
|
||||
lineItems: [{ description: "<b>Cola</b>", quantity: 2, price: 2.5, unitPrice: 1.25, taxRate: 19 }],
|
||||
suggestedCategory: "Sonstiges<script>",
|
||||
validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null },
|
||||
rawText: "OCR <img onerror=1> line\n2",
|
||||
paymentMethod: "CASH",
|
||||
createdAt: "2026-08-15T10:00:00.000Z",
|
||||
};
|
||||
const batch = sanitizeReceiptBatch([evil]);
|
||||
expect(batch.ok === true, "malicious-but-structurally-valid payload accepted (sanitized)");
|
||||
if (batch.ok && batch.receipts) {
|
||||
const r = batch.receipts[0];
|
||||
expect(!r.merchant.name.includes("<") && !r.merchant.name.includes(">"), "merchant markup stripped");
|
||||
expect(!r.suggestedCategory.includes("<script>"), "category markup stripped");
|
||||
expect(!r.rawText.includes("<"), "rawText markup stripped");
|
||||
expect(r.currency === "EUR", "currency normalized");
|
||||
expect(r.id === "r1", "id sanitized (markup tag removed)");
|
||||
}
|
||||
|
||||
console.log("[5] sanitizeReceiptBatch — structural rejection");
|
||||
expect(sanitizeReceiptBatch(null).ok === false, "null payload rejected");
|
||||
expect(sanitizeReceiptBatch({}).ok === false, "empty object rejected");
|
||||
expect(sanitizeReceiptBatch([]).ok === false, "empty array rejected");
|
||||
expect(sanitizeReceiptBatch([{ id: 42 }]).ok === false, "non-string id rejected");
|
||||
expect(sanitizeReceiptBatch([{ id: "x", merchant: { name: 5 } }]).ok === false, "non-string merchant name rejected");
|
||||
expect(sanitizeReceiptBatch([{ id: "x", currency: "EURO" }]).ok === false, "bad currency rejected");
|
||||
expect(sanitizeReceiptBatch([{ id: "x", totalAmount: { value: NaN } }]).ok === false, "NaN amount rejected");
|
||||
const tooMany = Array.from({ length: 501 }, (_, i) => ({ id: `r${i}`, currency: "EUR", totalAmount: { value: 1 } }));
|
||||
expect(sanitizeReceiptBatch(tooMany).ok === false, "501 receipts rejected");
|
||||
expect(sanitizeReceiptBatch(tooMany).error.includes("Too many"), "batch-size error message");
|
||||
|
||||
console.log("[6] sanitizeReceipt single variant");
|
||||
expect(sanitizeReceipt(evil) !== null, "valid single receipt sanitized");
|
||||
expect(sanitizeReceipt({ id: "x" }) === null, "invalid single receipt → null");
|
||||
|
||||
console.log("[7] CSV formula injection — neutralizeFormulaPrefix");
|
||||
expect(neutralizeFormulaPrefix('=HYPERLINK("http://evil","x")') === "'=HYPERLINK(\"http://evil\",\"x\")", "= prefix neutralized");
|
||||
expect(neutralizeFormulaPrefix('+cmd|\' /C calc\'!A0') === "'+cmd|' /C calc'!A0", "+ non-number neutralized");
|
||||
expect(neutralizeFormulaPrefix('@SUM(1+1)') === "'@SUM(1+1)", "@ neutralized");
|
||||
expect(neutralizeFormulaPrefix('\t=1') === "'\t=1", "tab neutralized");
|
||||
expect(neutralizeFormulaPrefix('-5,00') === "-5,00", "negative number literal untouched (credit note)");
|
||||
expect(neutralizeFormulaPrefix('+1,25') === "+1,25", "positive number literal untouched");
|
||||
expect(neutralizeFormulaPrefix('-SUM(1+1)') === "'-SUM(1+1)", "- non-number neutralized");
|
||||
expect(neutralizeFormulaPrefix('Rewe Markt') === "Rewe Markt", "normal text untouched");
|
||||
|
||||
console.log(`\n${checks} checks, ${failed} failure(s)`);
|
||||
if (failed > 0) process.exit(1);
|
||||
console.log("SANITIZE VERIFICATION PASSED");
|
||||
249
scripts/verify_sensitive_paths.mjs
Normal file
249
scripts/verify_sensitive_paths.mjs
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Verification battery for the sensitive-path / directory-listing hardening
|
||||
* (src/lib/http/sensitivePaths.ts + its wiring in src/middleware.ts).
|
||||
*
|
||||
* Run: node --import ./scripts/register-next-server-resolve.mjs scripts/verify_sensitive_paths.mjs
|
||||
* (or: npx tsx scripts/verify_sensitive_paths.mjs)
|
||||
*
|
||||
* Asserts against the REAL implementation (imported pure function + wrapper),
|
||||
* not a re-implementation:
|
||||
* - blocked paths -> isSensitivePath() === true and blockSensitivePath()
|
||||
* returns a 404 NextResponse with {error:"Not found"}
|
||||
* - allowed paths -> isSensitivePath() === false and blockSensitivePath()
|
||||
* returns undefined (request processing continues)
|
||||
* - encoded bypasses-> blocked by the wrapper's raw-path check
|
||||
* Exits 0 on success, 1 on any failure.
|
||||
*/
|
||||
import { isSensitivePath, blockSensitivePath } from "../src/lib/http/sensitivePaths.ts";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function expect(condition, label) {
|
||||
checks++;
|
||||
if (!condition) {
|
||||
failures++;
|
||||
console.error(` FAIL: ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function describe(label, fn) {
|
||||
console.log(`\n${label}`);
|
||||
return fn();
|
||||
}
|
||||
|
||||
const BLOCKED_PATHS = [
|
||||
// dotfiles / dot-directories
|
||||
"/.env",
|
||||
"/.env.local",
|
||||
"/.env.example",
|
||||
"/.git/config",
|
||||
"/.gitignore",
|
||||
"/.dockerignore",
|
||||
"/.npmrc",
|
||||
"/.next/BUILD_ID",
|
||||
"/.next-corrupt-20260815-2345/foo",
|
||||
"/.next-corrupt-20260815-2350/server.js",
|
||||
"/api/.env", // dotfile at depth
|
||||
// non-web-facing directories
|
||||
"/node_modules/x",
|
||||
"/node_modules/next/dist/server.js",
|
||||
"/drizzle/0000_init.sql",
|
||||
"/scripts/generate_demo_images.mjs",
|
||||
"/foo/node_modules/x",
|
||||
// project / build / config files (root + at depth)
|
||||
"/docker-compose.yml",
|
||||
"/docker-compose.override.yml",
|
||||
"/Dockerfile",
|
||||
"/deep/dir/Dockerfile",
|
||||
"/build_err.txt",
|
||||
"/tsconfig.json",
|
||||
"/tsconfig.tsbuildinfo",
|
||||
"/next.config.ts",
|
||||
"/drizzle.config.ts",
|
||||
"/package.json",
|
||||
"/package-lock.json",
|
||||
"/foo/package.json",
|
||||
// markdown documentation
|
||||
"/PROJECT.md",
|
||||
"/ORIGINAL_REQUEST.md",
|
||||
"/PROMPT_GOAL.md",
|
||||
"/DESIGN (1).md",
|
||||
"/AUTH_SETUP_GUIDE.md",
|
||||
"/STRIPE_SETUP_GUIDE.md",
|
||||
"/TEST_INFRA.md",
|
||||
"/receipt_scanner_to_excel_blueprint.md",
|
||||
"/deep/dir/notes.md",
|
||||
// sensitive extensions
|
||||
"/deep/dir/secret.pem",
|
||||
"/deep/dir/private.key",
|
||||
"/deep/dir/cert.crt",
|
||||
"/deep/dir/app.log",
|
||||
// path traversal / malformed input (defensive)
|
||||
"/foo/../bar",
|
||||
"/foo/./bar",
|
||||
"no-leading-slash",
|
||||
"",
|
||||
];
|
||||
|
||||
const ALLOWED_PATHS = [
|
||||
"/",
|
||||
"/de",
|
||||
"/en",
|
||||
"/dashboard",
|
||||
"/api/receipts",
|
||||
"/api/receipts/123",
|
||||
"/api/auth/login",
|
||||
"/api/auth/callback",
|
||||
"/showcase/01_synthwave_cyberpunk_sunset.png",
|
||||
"/showcase/05_watchmaker_cinematic_photo.jpg",
|
||||
"/app-icon.jpg",
|
||||
"/app-icon-original.png",
|
||||
"/demo/01_aral_tankbeleg_muenchen.jpg",
|
||||
"/favicon.ico",
|
||||
"/icon.png",
|
||||
"/apple-icon.png",
|
||||
"/robots.txt",
|
||||
"/sitemap.xml",
|
||||
"/admin",
|
||||
"/admin/users",
|
||||
"/admin/waitlist",
|
||||
"/admin/system",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
"/impressum",
|
||||
"/datenschutz",
|
||||
"/agb",
|
||||
"/_next/static/chunks/app/layout.js", // excluded by matcher anyway; policy must pass it
|
||||
];
|
||||
|
||||
const ENCODED_CASES = [
|
||||
// [url, expectBlocked, note]
|
||||
[
|
||||
"https://example.com/%2eenv",
|
||||
true,
|
||||
"encoded leading dot survives URL parsing -> raw %2e check blocks",
|
||||
],
|
||||
[
|
||||
"https://example.com/%252eenv",
|
||||
true,
|
||||
"double-encoded dot -> raw %25 check blocks",
|
||||
],
|
||||
[
|
||||
"https://example.com/%2e%2e/%2eenv",
|
||||
true,
|
||||
"encoded .. collapses but %2eenv remains -> raw %2e check blocks",
|
||||
],
|
||||
[
|
||||
"https://example.com/foo%5c..%5c.env",
|
||||
true,
|
||||
"encoded backslashes survive URL parsing -> raw %5c check blocks",
|
||||
],
|
||||
[
|
||||
"https://example.com/%2e%2e/%2e%2e/etc/passwd",
|
||||
false,
|
||||
"URL parser fully normalizes encoded .. -> neutralized upstream (/etc/passwd is not a route)",
|
||||
],
|
||||
[
|
||||
"https://example.com/foo\\bar",
|
||||
false,
|
||||
"URL parser normalizes raw backslash to '/' -> neutralized upstream",
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Wrapper-level subset: every blocked path the wrapper can actually see.
|
||||
* Paths with literal `.`/`..` segments and non-"/" inputs are pure-level
|
||||
* concerns — NextRequest URL parsing normalizes them away (or the middleware
|
||||
* never produces them), so they are asserted separately below.
|
||||
*/
|
||||
const WRAPPER_BLOCKED_PATHS = BLOCKED_PATHS.filter(
|
||||
(p) =>
|
||||
p.startsWith("/") &&
|
||||
!p.includes("/../") &&
|
||||
!p.includes("/./") &&
|
||||
p !== ".."
|
||||
);
|
||||
|
||||
function mockRequest(url) {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await describe("Pure predicate - blocked paths (expect isSensitivePath() === true)", () => {
|
||||
for (const p of BLOCKED_PATHS) {
|
||||
expect(isSensitivePath(p) === true, `should block ${JSON.stringify(p)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await describe("Pure predicate - allowed paths (expect isSensitivePath() === false)", () => {
|
||||
for (const p of ALLOWED_PATHS) {
|
||||
expect(isSensitivePath(p) === false, `should allow ${JSON.stringify(p)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await describe("Wrapper - blocked paths return 404 {error:'Not found'}", async () => {
|
||||
for (const p of WRAPPER_BLOCKED_PATHS) {
|
||||
const res = blockSensitivePath(mockRequest(`https://example.com${p}`));
|
||||
expect(res !== undefined, `should return a response for ${JSON.stringify(p)}`);
|
||||
if (res) {
|
||||
expect(res.status === 404, `status 404 for ${JSON.stringify(p)} (got ${res.status})`);
|
||||
const body = await res.json().catch(() => null);
|
||||
expect(
|
||||
body && typeof body === "object" && body.error === "Not found",
|
||||
`body {error:'Not found'} for ${JSON.stringify(p)} (got ${JSON.stringify(body)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await describe("Wrapper - allowed paths return undefined (processing continues)", () => {
|
||||
for (const p of ALLOWED_PATHS) {
|
||||
const res = blockSensitivePath(mockRequest(`https://example.com${p}`));
|
||||
expect(res === undefined, `should continue for ${JSON.stringify(p)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await describe("Wrapper - literal traversal segments are neutralized by URL parsing", () => {
|
||||
// NextRequest uses the WHATWG URL parser, which collapses literal
|
||||
// `.`/`..` segments and drops non-"/" inputs before middleware code runs.
|
||||
// The pure predicate still blocks them if they ever appear (defense
|
||||
// against a proxy that forwards un-normalized paths).
|
||||
const neutralized = ["/foo/../bar", "/foo/./bar", "no-leading-slash", ""];
|
||||
for (const p of neutralized) {
|
||||
expect(isSensitivePath(p) === true, `pure predicate still blocks ${JSON.stringify(p)}`);
|
||||
const res = blockSensitivePath(mockRequest(`https://example.com${p}`));
|
||||
expect(
|
||||
res === undefined,
|
||||
`wrapper returns undefined for ${JSON.stringify(p)} (URL parser normalized it; router 404s the result)`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await describe("Wrapper - percent-encoded traversal bypasses", () => {
|
||||
for (const [url, expectBlocked, note] of ENCODED_CASES) {
|
||||
const req = mockRequest(url);
|
||||
// informational: show what nextUrl.pathname looked like after URL parsing
|
||||
console.log(` nextUrl.pathname of ${url} -> ${JSON.stringify(req.nextUrl.pathname)}`);
|
||||
const res = blockSensitivePath(req);
|
||||
if (expectBlocked) {
|
||||
expect(res !== undefined, `should block ${url} (${note})`);
|
||||
if (res) expect(res.status === 404, `status 404 for ${url}`);
|
||||
} else {
|
||||
expect(res === undefined, `neutralized upstream (returns undefined): ${url} (${note})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => {
|
||||
console.log(`\n${checks} checks, ${failures} failure(s)`);
|
||||
if (failures > 0) process.exitCode = 1;
|
||||
},
|
||||
(err) => {
|
||||
console.error("Battery crashed:", err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user