Add developer tools page and secure guest scans
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
server/package-lock.json
generated
17
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user