Add developer tools page and secure guest scans

This commit is contained in:
2026-08-02 00:10:51 +02:00
parent c9e776ae98
commit c79e7b59ff
13 changed files with 328 additions and 33 deletions

View File

@@ -4,6 +4,7 @@ const crypto = require('crypto');
const dotenv = require('dotenv');
const express = require('express');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const loadEnvFiles = (filePaths) => {
const mergedFileEnv = {};
@@ -68,7 +69,7 @@ const {
identifyPlant,
isConfigured: isOpenAiConfigured,
} = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { applyCatalogGrounding, enforceEnglishName, normalizeText } = require('./lib/scanGrounding');
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const { prepareTiktokPhotoUrls } = require('./lib/tiktok-assets');
@@ -82,6 +83,9 @@ const {
} = require('./lib/tiktok');
const app = express();
// Caddy is the only reverse proxy in front of this service (see
// greenlns-landing/Caddyfile), so trust exactly one hop for req.ip / X-Forwarded-For.
app.set('trust proxy', 1);
const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
@@ -756,7 +760,25 @@ app.post('/v1/billing/sync-revenuecat', async (request, response) => {
}
});
app.post('/v1/scan', async (request, response) => {
// The 'Bearer guest' token (see resolveUserId above) grants a free,
// pre-auth demo scan with no credit charge. Without a limit here, that
// path is an unmetered, unauthenticated OpenAI proxy for anyone who
// knows the header — cap it per-IP so it stays a teaser, not a leak.
const guestScanLimiter = rateLimit({
windowMs: 24 * 60 * 60 * 1000,
limit: 3,
standardHeaders: true,
legacyHeaders: false,
skip: (request) => request.header('authorization') !== 'Bearer guest',
message: {
error: {
code: 'GUEST_SCAN_LIMIT',
message: 'Daily free scan limit reached. Sign in or download the app for unlimited scans.',
},
},
});
app.post('/v1/scan', guestScanLimiter, async (request, response) => {
let userId = 'unknown';
let idempotencyKey = null;
let creditsCharged = 0;
@@ -924,6 +946,8 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('review-skipped-free-plan');
}
result = enforceEnglishName(result, language);
const payload = {
result,
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,

View File

@@ -29,6 +29,17 @@ const GERMAN_COMMON_NAME_HINTS = [
'glueckskastanie',
];
// Most of the catalog's German common names are compound nouns ending in one
// of these — a hardcoded name list alone (GERMAN_COMMON_NAME_HINTS) only
// catches names we've already seen slip through, so this suffix check acts
// as a general-purpose backstop for names we haven't hardcoded.
const GERMAN_NAME_SUFFIXES = [
'blume', 'blueten', 'bluten', 'baum', 'strauch', 'kraut', 'wurz', 'wurzel',
'farn', 'palme', 'pflanze', 'hanf', 'tute', 'veilchen', 'dorn', 'kaktus',
'lilie', 'birke', 'weide', 'ranke', 'stern', 'kette', 'schwanz', 'ohren',
'feige', 'moos', 'zwiebel', 'knolle', 'rohr', 'kirsche',
];
const isLikelyGermanCommonName = (value) => {
const raw = String(value || '').trim();
if (!raw) return false;
@@ -36,11 +47,27 @@ const isLikelyGermanCommonName = (value) => {
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
if (!normalized) return false;
if (/\b(der|die|das|ein|eine)\b/.test(normalized)) return true;
if (/\b(der|die|das|ein|eine|und|mit|fuer)\b/.test(normalized)) return true;
const compact = normalized.replace(/\s+/g, '');
if (GERMAN_NAME_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return true;
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
};
// Last-resort safety net applied to the FINAL result regardless of whether
// it went through catalog grounding — the AI is instructed to never return a
// German name when English was requested, but doesn't always comply, so this
// catches the ones that slip through and falls back to the botanical name
// (which is language-neutral) rather than showing a mismatched German name
// alongside English description/care text.
const enforceEnglishName = (result, language) => {
if (!result || language !== 'en') return result;
if (!isLikelyGermanCommonName(result.name)) return result;
if (!result.botanicalName) return result;
return { ...result, name: result.botanicalName };
};
const isLikelyBotanicalName = (value, botanicalName) => {
const raw = String(value || '').trim();
const botanicalRaw = String(botanicalName || '').trim();
@@ -124,6 +151,7 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
module.exports = {
applyCatalogGrounding,
enforceEnglishName,
findCatalogMatch,
isLikelyGermanCommonName,
normalizeText,

View File

@@ -12,6 +12,7 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-rate-limit": "^7.5.1",
"minio": "^8.0.5",
"pg": "^8.16.3",
"sharp": "^0.34.5",
@@ -796,6 +797,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -834,6 +836,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",

View File

@@ -17,6 +17,7 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-rate-limit": "^7.5.1",
"minio": "^8.0.5",
"pg": "^8.16.3",
"sharp": "^0.34.5",