Slefhostet und postgres
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY server/. .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -26,7 +26,7 @@ loadEnvFiles([
|
||||
path.join(__dirname, '.env.local'),
|
||||
]);
|
||||
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase, get, run } = require('./lib/sqlite');
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
|
||||
const {
|
||||
PlantImportValidationError,
|
||||
@@ -392,18 +392,31 @@ app.get('/', (_request, response) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', (_request, response) => {
|
||||
const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
response.status(200).json({
|
||||
ok: true,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: isOpenAiConfigured(),
|
||||
dbReady: Boolean(db),
|
||||
dbPath: getDefaultDbPath(),
|
||||
stripeConfigured: Boolean(stripeSecret),
|
||||
stripeMode: getStripeSecretMode(),
|
||||
stripePublishableMode: getStripePublishableMode(),
|
||||
const getDatabaseHealthTarget = () => {
|
||||
const raw = getDefaultDbPath();
|
||||
if (!raw) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const databaseName = parsed.pathname.replace(/^\//, '');
|
||||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`;
|
||||
} catch {
|
||||
return 'configured';
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/health', (_request, response) => {
|
||||
const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
response.status(200).json({
|
||||
ok: true,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: isOpenAiConfigured(),
|
||||
dbReady: Boolean(db),
|
||||
dbPath: getDatabaseHealthTarget(),
|
||||
stripeConfigured: Boolean(stripeSecret),
|
||||
stripeMode: getStripeSecretMode(),
|
||||
stripePublishableMode: getStripePublishableMode(),
|
||||
scanModel: getScanModel(),
|
||||
healthModel: getHealthModel(),
|
||||
});
|
||||
@@ -500,12 +513,12 @@ app.post('/api/payment-sheet', async (request, response) => {
|
||||
|
||||
app.get('/v1/billing/summary', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
if (userId !== 'guest') {
|
||||
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]);
|
||||
if (!userExists) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
|
||||
}
|
||||
const userId = ensureRequestAuth(request);
|
||||
if (userId !== 'guest') {
|
||||
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]);
|
||||
if (!userExists) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
|
||||
}
|
||||
}
|
||||
const summary = await getBillingSummary(db, userId);
|
||||
response.status(200).json(summary);
|
||||
@@ -522,10 +535,11 @@ app.post('/v1/billing/sync-revenuecat', async (request, response) => {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' });
|
||||
}
|
||||
const customerInfo = request.body?.customerInfo;
|
||||
const source = typeof request.body?.source === 'string' ? request.body.source : undefined;
|
||||
if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' });
|
||||
}
|
||||
const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo);
|
||||
const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source });
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const crypto = require('crypto');
|
||||
const { get, run } = require('./sqlite');
|
||||
const { get, run } = require('./postgres');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
|
||||
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
|
||||
@@ -52,47 +52,51 @@ const hashPassword = (password) =>
|
||||
|
||||
// ─── Schema ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ensureAuthSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
);
|
||||
};
|
||||
const ensureAuthSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Operations ───────────────────────────────────────────────────────────
|
||||
|
||||
const signUp = async (db, email, name, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const existing = await get(db, 'SELECT id FROM auth_users WHERE email = ?', [normalizedEmail]);
|
||||
if (existing) {
|
||||
const err = new Error('Email already in use.');
|
||||
err.code = 'EMAIL_TAKEN';
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES (?, ?, ?, ?)', [
|
||||
id,
|
||||
normalizedEmail,
|
||||
name.trim(),
|
||||
hashPassword(password),
|
||||
]);
|
||||
const signUp = async (db, email, name, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const existing = await get(db, 'SELECT id FROM auth_users WHERE LOWER(email) = LOWER($1)', [normalizedEmail]);
|
||||
if (existing) {
|
||||
const err = new Error('Email already in use.');
|
||||
err.code = 'EMAIL_TAKEN';
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES ($1, $2, $3, $4)', [
|
||||
id,
|
||||
normalizedEmail,
|
||||
name.trim(),
|
||||
hashPassword(password),
|
||||
]);
|
||||
return { id, email: normalizedEmail, name: name.trim() };
|
||||
};
|
||||
|
||||
const login = async (db, email, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const user = await get(db, 'SELECT id, email, name, password_hash FROM auth_users WHERE email = ?', [normalizedEmail]);
|
||||
if (!user) {
|
||||
const err = new Error('No account found for this email.');
|
||||
err.code = 'USER_NOT_FOUND';
|
||||
err.status = 401;
|
||||
const login = async (db, email, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const user = await get(
|
||||
db,
|
||||
'SELECT id, email, name, password_hash FROM auth_users WHERE LOWER(email) = LOWER($1)',
|
||||
[normalizedEmail],
|
||||
);
|
||||
if (!user) {
|
||||
const err = new Error('No account found for this email.');
|
||||
err.code = 'USER_NOT_FOUND';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (user.password_hash !== hashPassword(password)) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1462
server/lib/plants.js
1462
server/lib/plants.js
File diff suppressed because it is too large
Load Diff
93
server/lib/postgres.js
Normal file
93
server/lib/postgres.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const parseBoolean = (value, fallback = false) => {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const buildDatabaseUrlFromParts = () => {
|
||||
const host = (process.env.POSTGRES_HOST || 'postgres').trim();
|
||||
const port = Number(process.env.POSTGRES_PORT || 5432);
|
||||
const database = (process.env.POSTGRES_DB || 'greenlns').trim();
|
||||
const user = (process.env.POSTGRES_USER || 'greenlns').trim();
|
||||
const password = process.env.POSTGRES_PASSWORD;
|
||||
if (!password) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${encodeURIComponent(database)}`;
|
||||
};
|
||||
|
||||
const getDefaultDbPath = () => {
|
||||
return (process.env.DATABASE_URL || buildDatabaseUrlFromParts()).trim();
|
||||
};
|
||||
|
||||
const getPoolConfig = () => {
|
||||
const connectionString = getDefaultDbPath();
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL or POSTGRES_* environment variables are required.');
|
||||
}
|
||||
|
||||
const sslEnabled = parseBoolean(process.env.DATABASE_SSL, false);
|
||||
return {
|
||||
connectionString,
|
||||
max: Number(process.env.PGPOOL_MAX || 10),
|
||||
ssl: sslEnabled ? { rejectUnauthorized: false } : false,
|
||||
};
|
||||
};
|
||||
|
||||
const translateSql = (sql) => {
|
||||
if (typeof sql !== 'string') return sql;
|
||||
|
||||
let placeholderIndex = 0;
|
||||
return sql
|
||||
.replace(/\?/g, () => {
|
||||
placeholderIndex += 1;
|
||||
return `$${placeholderIndex}`;
|
||||
})
|
||||
.replace(/BEGIN\s+IMMEDIATE\s+TRANSACTION/gi, 'BEGIN')
|
||||
.replace(/datetime\('now'\)/gi, 'CURRENT_TIMESTAMP')
|
||||
.replace(/\s+COLLATE\s+NOCASE/gi, '');
|
||||
};
|
||||
|
||||
const openDatabase = async () => {
|
||||
const pool = new Pool(getPoolConfig());
|
||||
await pool.query('SELECT 1');
|
||||
return pool;
|
||||
};
|
||||
|
||||
const closeDatabase = async (db) => {
|
||||
if (!db || typeof db.end !== 'function') return;
|
||||
await db.end();
|
||||
};
|
||||
|
||||
const run = async (db, sql, params = []) => {
|
||||
const result = await db.query(translateSql(sql), params);
|
||||
return {
|
||||
lastId: result.rows?.[0]?.id ?? null,
|
||||
changes: result.rowCount || 0,
|
||||
rows: result.rows || [],
|
||||
};
|
||||
};
|
||||
|
||||
const get = async (db, sql, params = []) => {
|
||||
const result = await db.query(translateSql(sql), params);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
const all = async (db, sql, params = []) => {
|
||||
const result = await db.query(translateSql(sql), params);
|
||||
return result.rows || [];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
all,
|
||||
closeDatabase,
|
||||
get,
|
||||
getDefaultDbPath,
|
||||
openDatabase,
|
||||
run,
|
||||
};
|
||||
@@ -1,86 +1 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const getDefaultDbPath = () => {
|
||||
return process.env.PLANT_DB_PATH || path.join(__dirname, '..', 'data', 'greenlns.sqlite');
|
||||
};
|
||||
|
||||
const ensureDbDirectory = (dbPath) => {
|
||||
const directory = path.dirname(dbPath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
};
|
||||
|
||||
const openDatabase = (dbPath = getDefaultDbPath()) => {
|
||||
ensureDbDirectory(dbPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = new sqlite3.Database(dbPath, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(db);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const closeDatabase = (db) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const run = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function onRun(error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
lastId: this.lastID,
|
||||
changes: this.changes,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const get = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row || null);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const all = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (error, rows) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(rows || []);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
all,
|
||||
closeDatabase,
|
||||
get,
|
||||
getDefaultDbPath,
|
||||
openDatabase,
|
||||
run,
|
||||
};
|
||||
module.exports = require('./postgres');
|
||||
|
||||
1427
server/package-lock.json
generated
1427
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,13 +13,13 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"minio": "^8.0.5",
|
||||
"sharp": "^0.34.5",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stripe": "^20.3.1"
|
||||
}
|
||||
}
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"minio": "^8.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^20.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,13 +284,13 @@ const convertToWebp = async (inputBuffer, outputPath) => {
|
||||
fs.unlinkSync(tempPath);
|
||||
};
|
||||
|
||||
const updatePlantImageUri = async (db, plantId, localImageUri) => {
|
||||
await run(
|
||||
db,
|
||||
'UPDATE plants SET imageUri = ?, imageStatus = ?, updatedAt = datetime(\'now\') WHERE id = ?',
|
||||
[localImageUri, 'ok', plantId],
|
||||
);
|
||||
};
|
||||
const updatePlantImageUri = async (db, plantId, localImageUri) => {
|
||||
await run(
|
||||
db,
|
||||
'UPDATE plants SET image_uri = $1, image_status = $2, updated_at = NOW() WHERE id = $3',
|
||||
[localImageUri, 'ok', plantId],
|
||||
);
|
||||
};
|
||||
|
||||
const processPlant = async (db, plant, manifestItems, dumpFallbackMap, searchCache, refreshMatchers) => {
|
||||
const currentUri = String(plant.imageUri || '').trim();
|
||||
@@ -402,12 +402,12 @@ const main = async () => {
|
||||
|
||||
try {
|
||||
await ensurePlantSchema(db);
|
||||
const plants = await all(
|
||||
db,
|
||||
`SELECT id, name, botanicalName, imageUri
|
||||
FROM plants
|
||||
ORDER BY name COLLATE NOCASE ASC`,
|
||||
);
|
||||
const plants = await all(
|
||||
db,
|
||||
`SELECT id, name, botanical_name AS "botanicalName", image_uri AS "imageUri"
|
||||
FROM plants
|
||||
ORDER BY LOWER(name) ASC`,
|
||||
);
|
||||
|
||||
console.log(`Preparing ${plants.length} plant images...`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user