api.ts raw
1 import { Hono } from 'hono';
2 import { cors } from 'hono/cors';
3 import { getAllProducts, getProductBySlug, getProductsByCategory, getCategories } from './lib/products';
4 import {
5 verifyPassword, findOrCreateNostrUser, createSession, getUserFromSession,
6 deleteSession, issueNostrChallenge, verifyNostrAuthEvent, createUser,
7 updateUserPrefs, setUserPassword,
8 } from './lib/auth';
9 import { getCities, getOfficesByCourier } from './lib/couriers';
10 import { createOrder, getOrderForView, orderView, setPaymentId, CheckoutError } from './lib/orders';
11 import { createPayment } from './lib/payments';
12
13 const api = new Hono();
14
15 api.use('/*', cors({ origin: process.env.SITE_URL || 'http://localhost:3000' }));
16
17 // --- simple in-memory rate limiter for auth endpoints ---
18 const buckets = new Map<string, { count: number; reset: number }>();
19 function rateLimit(key: string, max: number, windowMs: number): boolean {
20 const now = Date.now();
21 const b = buckets.get(key);
22 if (!b || b.reset < now) {
23 buckets.set(key, { count: 1, reset: now + windowMs });
24 return true;
25 }
26 b.count++;
27 return b.count <= max;
28 }
29 function clientKey(c: any): string {
30 return c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || 'local';
31 }
32
33 // --- catalog ---
34 function stripeConfigKey(key: string): string {
35 const envMap: Record<string, string> = {
36 stripe_publishable_key: 'STRIPE_PUBLISHABLE_KEY',
37 stripe_secret_key: 'STRIPE_SECRET_KEY',
38 stripe_webhook_secret: 'STRIPE_WEBHOOK_SECRET',
39 btcpay_url: 'BTCPAY_URL',
40 btcpay_api_key: 'BTCPAY_API_KEY',
41 btcpay_store_id: 'BTCPAY_STORE_ID',
42 };
43 const envVar = envMap[key];
44 const env = envVar ? process.env[envVar] : undefined;
45 if (env) return env;
46 const db = (() => { try { return require('./lib/db').getDb(); } catch { return null; } })();
47 if (!db) return '';
48 const row = db.query('SELECT value FROM content WHERE key = ?').get(key) as { value: string } | null;
49 return row?.value || '';
50 }
51
52 api.get('/stripe-key', (c) => c.json({ publishableKey: stripeConfigKey('stripe_publishable_key') || 'pk_mock' }));
53
54 api.post('/stripe/payment-intent', async (c) => {
55 try {
56 const { items } = await c.req.json();
57 if (!Array.isArray(items) || items.length === 0) return c.json({ error: 'items required' }, 400);
58 let amount = 0;
59 let weightKg = 0;
60 for (const it of items) {
61 const product = getProductBySlug(String(it?.slug ?? ''));
62 const qty = Number(it?.qty);
63 if (!product || !Number.isInteger(qty) || qty < 1) continue;
64 amount += product.price_cents * qty;
65 weightKg += (product.unit_size * qty) / 1000;
66 }
67 const { shippingCostCentsSync } = await import('./lib/shipping');
68 const shipping = shippingCostCentsSync(weightKg, amount);
69 const total = amount + shipping;
70
71 const Stripe = (await import('stripe')).default;
72 const key = stripeConfigKey('stripe_secret_key');
73 if (!key) {
74 return c.json({ clientSecret: null, amount: total });
75 }
76 const stripe = new Stripe(key);
77 const intent = await stripe.paymentIntents.create({
78 amount: total,
79 currency: 'eur',
80 automatic_payment_methods: { enabled: true },
81 });
82 return c.json({ clientSecret: intent.client_secret, amount: total });
83 } catch (e: any) {
84 console.error('payment-intent error:', e);
85 return c.json({ error: 'failed to create intent' }, 400);
86 }
87 });
88
89 api.get('/categories', (c) => c.json(getCategories()));
90
91 api.get('/products', (c) => {
92 const cat = c.req.query('cat');
93 return c.json(cat ? getProductsByCategory(cat) : getAllProducts());
94 });
95
96 api.get('/products/:slug', (c) => {
97 const product = getProductBySlug(c.req.param('slug'));
98 if (!product) return c.json({ error: 'not found' }, 404);
99 return c.json(product);
100 });
101
102 // --- auth ---
103 api.post('/auth/login', async (c) => {
104 if (!rateLimit('login:' + clientKey(c), 10, 60_000)) return c.json({ error: 'too many attempts' }, 429);
105 try {
106 const { email, password } = await c.req.json();
107 if (!email || !password) return c.json({ error: 'email and password required' }, 400);
108 const userId = verifyPassword(email, password);
109 if (!userId) return c.json({ error: 'invalid email or password' }, 401);
110 const token = createSession(userId);
111 return c.json({ token, user: { id: userId, email } });
112 } catch {
113 return c.json({ error: 'invalid request body' }, 400);
114 }
115 });
116
117 api.post('/auth/register', async (c) => {
118 if (!rateLimit('register:' + clientKey(c), 5, 300_000)) return c.json({ error: 'too many attempts' }, 429);
119 try {
120 const { email, password, name, phone, delivery_prefs } = await c.req.json();
121 if (!email || !password) return c.json({ error: 'email and password required' }, 400);
122 if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
123 return c.json({ error: 'valid email required' }, 400);
124 }
125 if (typeof password !== 'string' || password.length < 6) {
126 return c.json({ error: 'password must be at least 6 characters' }, 400);
127 }
128 const { getDb } = await import('./lib/db');
129 const existing = getDb().query('SELECT id FROM users WHERE email = ?').get(email);
130 if (existing) return c.json({ error: 'email already registered' }, 409);
131 const userId = createUser(email, password);
132 if (name || phone || delivery_prefs) {
133 updateUserPrefs(userId, { name, phone, delivery_prefs: delivery_prefs ? JSON.stringify(delivery_prefs) : undefined });
134 }
135 const token = createSession(userId);
136 return c.json({ token, user: { id: userId, email } });
137 } catch {
138 return c.json({ error: 'invalid request body' }, 400);
139 }
140 });
141
142 api.get('/auth/nostr-challenge', (c) => {
143 if (!rateLimit('challenge:' + clientKey(c), 30, 60_000)) return c.json({ error: 'too many attempts' }, 429);
144 return c.json({ challenge: issueNostrChallenge() });
145 });
146
147 api.post('/auth/nostr', async (c) => {
148 if (!rateLimit('nostr:' + clientKey(c), 10, 60_000)) return c.json({ error: 'too many attempts' }, 429);
149 try {
150 const { event } = await c.req.json();
151 const pubkey = verifyNostrAuthEvent(event);
152 if (!pubkey) return c.json({ error: 'invalid auth event' }, 401);
153 const { userId, created } = findOrCreateNostrUser(pubkey);
154 const token = createSession(userId);
155 return c.json({ token, user: { id: userId, pubkey }, new_user: created });
156 } catch {
157 return c.json({ error: 'invalid request body' }, 400);
158 }
159 });
160
161 api.get('/auth/session', (c) => {
162 const auth = c.req.header('Authorization');
163 if (!auth?.startsWith('Bearer ')) return c.json({ user: null });
164 const user = getUserFromSession(auth.slice(7));
165 return c.json({ user: user ? { id: user.id, email: user.email, nostr_pubkey: user.nostr_pubkey, is_admin: user.is_admin, delivery_prefs: user.delivery_prefs, name: user.name, phone: user.phone, has_password: !!user.password_hash } : null });
166 });
167
168 // --- user prefs ---
169 function authUser(c: any): any | null {
170 const auth = c.req.header('Authorization');
171 if (!auth?.startsWith('Bearer ')) return null;
172 return getUserFromSession(auth.slice(7));
173 }
174
175 api.put('/user/prefs', async (c) => {
176 const user = authUser(c);
177 if (!user) return c.json({ error: 'unauthorized' }, 401);
178 let body: any;
179 try {
180 body = await c.req.json();
181 const updated = updateUserPrefs(user.id, {
182 name: body.name,
183 phone: body.phone,
184 email: body.email,
185 delivery_prefs: body.delivery_prefs ? JSON.stringify(body.delivery_prefs) : undefined,
186 });
187 return c.json({ user: updated ? { id: updated.id, email: updated.email, nostr_pubkey: updated.nostr_pubkey, is_admin: updated.is_admin, delivery_prefs: updated.delivery_prefs, name: updated.name, phone: updated.phone } : null });
188 } catch (e: any) {
189 const msg = e?.message || String(e);
190 console.error('user/prefs error:', msg, 'userId:', user?.id, 'bodyKeys:', Object.keys(body || {}));
191 if (msg.includes('UNIQUE')) return c.json({ error: 'email address in use' }, 409);
192 return c.json({ error: msg || 'invalid request body' }, 400);
193 }
194 });
195
196 api.put('/user/password', async (c) => {
197 const user = authUser(c);
198 if (!user) return c.json({ error: 'unauthorized' }, 401);
199 const { password } = await c.req.json();
200 if (!password || password.length < 6) return c.json({ error: 'password must be at least 6 characters' }, 400);
201 const ok = setUserPassword(user.id, password);
202 return c.json({ ok });
203 });
204
205 // --- user orders ---
206 api.get('/user/orders', (c) => {
207 const user = authUser(c);
208 if (!user) return c.json({ error: 'unauthorized' }, 401);
209 const { getDb } = require('./lib/db');
210 const rows = getDb().query(
211 'SELECT id, total_cents, status, payment_method, payment_status, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 100'
212 ).all(user.id);
213 return c.json(rows);
214 });
215
216 api.get('/user/orders/:id', (c) => {
217 const user = authUser(c);
218 if (!user) return c.json({ error: 'unauthorized' }, 401);
219 const { getDb } = require('./lib/db');
220 const order = getDb().query(
221 'SELECT * FROM orders WHERE id = ? AND user_id = ?'
222 ).get(c.req.param('id'), user.id) as any;
223 if (!order) return c.json({ error: 'not found' }, 404);
224 const { orderView } = require('./lib/orders');
225 return c.json({ ...orderView(order), items: JSON.parse(order.items), customer_name: order.customer_name, customer_phone: order.customer_phone, shipping_address: order.shipping_address });
226 });
227
228 api.post('/user/orders/:id/cancel', async (c) => {
229 const user = authUser(c);
230 if (!user) return c.json({ error: 'unauthorized' }, 401);
231 try {
232 const { getOrder, transitionOrder } = await import('./lib/orders');
233 const order = getOrder(c.req.param('id'));
234 if (!order) return c.json({ error: 'not found' }, 404);
235 if (order.user_id !== user.id) return c.json({ error: 'forbidden' }, 403);
236 if (order.status !== 'pending') return c.json({ error: 'only pending orders can be cancelled' }, 400);
237 transitionOrder(order.id, 'cancelled');
238 return c.json({ ok: true });
239 } catch (e: any) {
240 return c.json({ error: e.message || 'cancel failed' }, 400);
241 }
242 });
243 function adminUser(c: any): any | null {
244 const user = authUser(c);
245 return user?.is_admin ? user : null;
246 }
247
248 api.get('/admin/users', (c) => {
249 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
250 const { getDb } = require('./lib/db');
251 const rows = getDb().query(
252 `SELECT u.id, u.email, u.nostr_pubkey, u.is_admin, u.name, u.delivery_prefs, u.created_at,
253 (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
254 FROM users u ORDER BY u.created_at DESC LIMIT 200`
255 ).all();
256 return c.json(rows);
257 });
258
259 api.post('/admin/users', async (c) => {
260 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
261 try {
262 const { email, password, is_admin } = await c.req.json();
263 if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
264 return c.json({ error: 'valid email required' }, 400);
265 }
266 if (typeof password !== 'string' || password.length < 6) {
267 return c.json({ error: 'password must be at least 6 characters' }, 400);
268 }
269 const { getDb } = require('./lib/db');
270 const existing = getDb().query('SELECT id FROM users WHERE email = ?').get(email);
271 if (existing) return c.json({ error: 'email already registered' }, 409);
272 const userId = createUser(email, password);
273 if (is_admin) {
274 getDb().query('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
275 }
276 return c.json({ ok: true, id: userId });
277 } catch {
278 return c.json({ error: 'create failed' }, 400);
279 }
280 });
281
282 api.post('/admin/users/:id/admin', async (c) => {
283 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
284 try {
285 const { is_admin } = await c.req.json();
286 if (typeof is_admin !== 'number' || (is_admin !== 0 && is_admin !== 1)) {
287 return c.json({ error: 'is_admin must be 0 or 1' }, 400);
288 }
289 const { getDb } = require('./lib/db');
290 getDb().query('UPDATE users SET is_admin = ? WHERE id = ?').run(is_admin, c.req.param('id'));
291 return c.json({ ok: true });
292 } catch {
293 return c.json({ error: 'bad request' }, 400);
294 }
295 });
296
297 api.get('/admin/users/:id/orders', (c) => {
298 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
299 const { getDb } = require('./lib/db');
300 const rows = getDb().query(
301 'SELECT id, customer_name, total_cents, status, payment_method, payment_status, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'
302 ).all(c.req.param('id'));
303 return c.json(rows);
304 });
305
306 api.post('/auth/logout', (c) => {
307 const auth = c.req.header('Authorization');
308 if (auth?.startsWith('Bearer ')) deleteSession(auth.slice(7));
309 return c.json({ ok: true });
310 });
311
312 // --- checkout ---
313 api.post('/checkout', async (c) => {
314 try {
315 const body = await c.req.json();
316 const auth = c.req.header('Authorization');
317 if (auth?.startsWith('Bearer ')) {
318 const user = getUserFromSession(auth.slice(7));
319 if (user) body.user_id = user.id;
320 }
321 const order = await createOrder(body);
322 let payment: any = null;
323 if (body.paymentIntentId) {
324 // express checkout paid already - mark as paid
325 const { getDb } = require('./lib/db');
326 getDb().query('UPDATE orders SET payment_status = ?, payment_id = ? WHERE id = ?').run('paid', body.paymentIntentId, order.id);
327 payment = { type: body.paymentMethod, paymentId: body.paymentIntentId };
328 } else {
329 try {
330 payment = await createPayment(order);
331 if (payment?.paymentId) setPaymentId(order.id, payment.paymentId);
332 } catch (e: any) {
333 payment = { type: order.payment_method, error: e.message || 'payment setup failed' };
334 }
335 }
336
337 // fire best-effort confirmation email
338 const { orderCreatedEmail } = await import('./lib/email');
339 const items = JSON.parse(order.items);
340 const desc = items.map((i: any) => `${i.name} × ${i.qty}кг`).join(', ');
341 orderCreatedEmail(order.customer_email, order.id, order.view_token, ((order.total_cents) / 100).toFixed(2) + ' лв', desc);
342 return c.json({
343 orderId: order.id,
344 viewToken: order.view_token,
345 status: order.status,
346 total_cents: order.total_cents,
347 shipping_cents: order.shipping_cents,
348 payment,
349 });
350 } catch (e: any) {
351 if (e.status && e.message) return c.json({ error: e.message }, e.status as any);
352 console.error('checkout error:', e);
353 return c.json({ error: 'checkout failed' }, 500);
354 }
355 });
356
357 api.get('/orders/:id', async (c) => {
358 let order = getOrderForView(c.req.param('id'), c.req.query('t') || '');
359 if (!order) return c.json({ error: 'not found' }, 404);
360 // lightning settles via LUD-21 verify polling - check on read while pending
361 if (order.status === 'pending' && order.payment_method === 'lightning' && order.payment_id) {
362 const { checkLightningPaid } = await import('./lib/payments');
363 if (await checkLightningPaid(order.payment_id)) {
364 const { transitionOrder } = await import('./lib/orders');
365 order = transitionOrder(order.id, 'paid');
366 }
367 }
368 return c.json(orderView(order));
369 });
370
371 // shipping preview for the checkout page - server-priced, same path as createOrder
372 api.post('/shipping-quote', async (c) => {
373 try {
374 const { items, city, courier, cod, officeDelivery } = await c.req.json();
375 if (!Array.isArray(items)) return c.json({ error: 'items required' }, 400);
376 let subtotal = 0;
377 let weightKg = 0;
378 for (const it of items) {
379 const product = getProductBySlug(String(it?.slug ?? ''));
380 const qty = Number(it?.qty);
381 if (!product || !Number.isInteger(qty) || qty < 1) continue;
382 subtotal += product.price_cents * qty;
383 weightKg += (product.unit_size * qty) / 1000;
384 }
385 const { shippingCostCents } = await import('./lib/shipping');
386 const shipping = await shippingCostCents(weightKg, subtotal, city || undefined, courier || undefined, cod ? subtotal : 0, officeDelivery || false);
387 return c.json({ subtotal_cents: subtotal, shipping_cents: shipping, total_cents: subtotal + shipping });
388 } catch (e: any) {
389 console.error('shipping-quote error:', e);
390 return c.json({ error: 'invalid request body' }, 400);
391 }
392 });
393
394 // --- payment webhooks (public path: /api/webhooks/... via caddy) ---
395 api.post('/webhooks/stripe', async (c) => {
396 const secret = process.env.STRIPE_WEBHOOK_SECRET;
397 const key = process.env.STRIPE_SECRET_KEY;
398 if (!secret || !key) return c.json({ error: 'not configured' }, 501);
399 const sig = c.req.header('stripe-signature');
400 if (!sig) return c.json({ error: 'missing signature' }, 400);
401 try {
402 const Stripe = (await import('stripe')).default;
403 const stripe = new Stripe(key);
404 const body = await c.req.text();
405 const event = await stripe.webhooks.constructEventAsync(body, sig, secret);
406 if (event.type === 'payment_intent.succeeded') {
407 const intent = event.data.object as any;
408 const { markPaidByPaymentId, getOrder } = await import('./lib/orders');
409 const paid = markPaidByPaymentId(intent.id);
410 if (paid) {
411 const { paymentConfirmedEmail } = await import('./lib/email');
412 paymentConfirmedEmail(paid.customer_email, paid.id);
413 }
414 } else if (event.type === 'payment_intent.payment_failed') {
415 const intent = event.data.object as any;
416 const { getDb } = await import('./lib/db');
417 const order = getDb().query('SELECT id, status FROM orders WHERE payment_id = ?').get(intent.id) as any;
418 if (order?.status === 'pending') {
419 const { transitionOrder } = await import('./lib/orders');
420 transitionOrder(order.id, 'failed');
421 }
422 }
423 return c.json({ received: true });
424 } catch (e: any) {
425 return c.json({ error: 'signature verification failed' }, 400);
426 }
427 });
428
429 api.post('/webhooks/btcpay', async (c) => {
430 const secret = process.env.BTCPAY_WEBHOOK_SECRET;
431 if (!secret) return c.json({ error: 'not configured' }, 501);
432 const sig = c.req.header('btcpay-sig');
433 if (!sig) return c.json({ error: 'missing signature' }, 400);
434 const body = await c.req.text();
435 const crypto = await import('crypto');
436 const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
437 const sigBuf = Buffer.from(sig);
438 const expBuf = Buffer.from(expected);
439 if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
440 return c.json({ error: 'signature verification failed' }, 400);
441 }
442 try {
443 const event = JSON.parse(body);
444 const orderId = event.metadata?.orderId;
445 if (orderId && (event.type === 'InvoiceSettled' || event.type === 'InvoicePaymentSettled')) {
446 const { getOrder, transitionOrder } = await import('./lib/orders');
447 const order = getOrder(orderId);
448 if (order && order.status === 'pending') {
449 transitionOrder(orderId, 'paid');
450 const { paymentConfirmedEmail } = await import('./lib/email');
451 paymentConfirmedEmail(order.customer_email, orderId);
452 }
453 } else if (orderId && event.type === 'InvoiceExpired') {
454 const { getOrder, transitionOrder } = await import('./lib/orders');
455 const order = getOrder(orderId);
456 if (order?.status === 'pending') transitionOrder(orderId, 'expired');
457 }
458 return c.json({ received: true });
459 } catch {
460 return c.json({ error: 'bad payload' }, 400);
461 }
462 });
463
464 // --- couriers ---
465 api.get('/cities', (c) => c.json(getCities()));
466
467 api.get('/offices', (c) => {
468 const city = c.req.query('city');
469 const courier = c.req.query('courier');
470 let offices = getOfficesByCourier(courier || '');
471 if (city) offices = offices.filter(o => o.city.toLowerCase().includes(city.toLowerCase()));
472 return c.json(offices);
473 });
474
475 // --- admin orders ---
476 api.get('/admin/orders', (c) => {
477 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
478 const { getDb } = require('./lib/db');
479 const orders = getDb().query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 200').all();
480 return c.json(orders);
481 });
482
483 api.post('/admin/orders/:id/status', async (c) => {
484 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
485 try {
486 const { to } = await c.req.json();
487 const { transitionOrder } = await import('./lib/orders');
488 const order = transitionOrder(c.req.param('id'), to);
489 if (to === 'paid') {
490 const { paymentConfirmedEmail } = await import('./lib/email');
491 paymentConfirmedEmail(order.customer_email, order.id);
492 }
493 return c.json(order);
494 } catch (e: any) {
495 if (e.status && e.message) return c.json({ error: e.message }, e.status as any);
496 return c.json({ error: 'transition failed' }, 500);
497 }
498 });
499
500 api.post('/admin/orders/:id/label', async (c) => {
501 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
502 try {
503 const { getOrder } = await import('./lib/orders');
504 const order = getOrder(c.req.param('id'));
505 if (!order) return c.json({ error: 'order not found' }, 404);
506 if (order.status !== 'paid' && order.status !== 'pending') return c.json({ error: 'order must be paid or pending' }, 400);
507
508 const items: any[] = JSON.parse(order.items);
509 const addr: any = JSON.parse(order.shipping_address || '{}');
510 const weightKg = items.reduce((s: number, it: any) => s + (it.unit_size * it.qty) / 1000, 0);
511
512 const { econtCreateLabel } = await import('./lib/econt');
513 const result = await econtCreateLabel(
514 order.customer_name,
515 order.customer_phone || '',
516 addr.city || '',
517 addr.address || '',
518 addr.postCode || '',
519 addr.address2 || '',
520 weightKg,
521 order.shipping_method === 'office',
522 order.payment_method === 'cod' ? order.total_cents : 0,
523 );
524
525 if (result.error) return c.json({ error: result.error }, 400);
526
527 const { getDb } = await import('./lib/db');
528 getDb().query("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('shipped', order.id);
529 getDb().query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(`label_${order.id}`, JSON.stringify(result));
530
531 return c.json(result);
532 } catch (e: any) {
533 console.error('label creation error:', e);
534 return c.json({ error: e.message || 'label creation failed' }, 400);
535 }
536 });
537
538 api.post('/admin/products', async (c) => {
539 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
540 try {
541 const p = await c.req.json();
542 if (!p.name || !p.slug || !Number.isInteger(p.price_cents) || p.price_cents < 0) {
543 return c.json({ error: 'name, slug, price_cents required' }, 400);
544 }
545 const { getDb } = await import('./lib/db');
546 const maxOrd = (getDb().query('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM products').get() as { n: number }).n;
547 getDb().query(`INSERT INTO products (name, name_en, slug, description, description_en, price_cents, unit_size, unit, images, category, stock_count, harvest_year, ord)
548 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
549 p.name, p.name_en || '', p.slug, p.description || '', p.description_en || '',
550 p.price_cents, p.unit_size ?? 100, p.unit || 'g', p.images || '[]', p.category || 'bilki', p.stock_count ?? 0, p.harvest_year || null, maxOrd);
551 return c.json(getProductBySlug(p.slug));
552 } catch (e: any) {
553 return c.json({ error: e.message?.includes('UNIQUE') ? 'slug already exists' : 'create failed' }, 400);
554 }
555 });
556
557 api.put('/admin/products/:id', async (c) => {
558 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
559 let p: any = {};
560 try {
561 p = await c.req.json();
562 const id = parseInt(c.req.param('id'));
563 const { getDb } = await import('./lib/db');
564 const existing = getDb().query('SELECT id FROM products WHERE id = ?').get(id);
565 if (!existing) return c.json({ error: 'not found' }, 404);
566 getDb().query(`UPDATE products SET name = ?, name_en = ?, slug = ?, description = ?, description_en = ?,
567 price_cents = ?, unit_size = ?, unit = ?, images = ?, category = ?, stock_count = ?, harvest_year = ?, usage = ?, usage_en = ? WHERE id = ?`).run(
568 p.name, p.name_en || '', p.slug, p.description || '', p.description_en || '',
569 Number.isFinite(p.price_cents) ? p.price_cents : 100, p.unit_size ?? 100, p.unit || 'g', p.images || '[]', p.category || 'bilki', Number.isFinite(p.stock_count) ? p.stock_count : 0, p.harvest_year || null,
570 p.usage || '', p.usage_en || '', id);
571 return c.json(getDb().query('SELECT * FROM products WHERE id = ?').get(id));
572 } catch (e: any) {
573 console.error('admin/products PUT failed:', e?.message || e, 'body:', JSON.stringify(p || {}));
574 return c.json({ error: 'update failed' }, 400);
575 }
576 });
577
578 api.delete('/admin/products/:id', async (c) => {
579 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
580 const { getDb } = await import('./lib/db');
581 getDb().query('DELETE FROM products WHERE id = ?').run(parseInt(c.req.param('id')));
582 return c.json({ ok: true });
583 });
584
585 api.post('/admin/products/:id/reorder', async (c) => {
586 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
587 try {
588 const { direction } = await c.req.json();
589 if (direction !== 'up' && direction !== 'down') return c.json({ error: 'invalid direction' }, 400);
590 const id = parseInt(c.req.param('id'));
591 const { getDb } = await import('./lib/db');
592 const cur = getDb().query('SELECT ord FROM products WHERE id = ?').get(id) as { ord: number } | null;
593 if (!cur) return c.json({ error: 'not found' }, 404);
594 const swapOrd = direction === 'up' ? cur.ord - 1 : cur.ord + 1;
595 const other = getDb().query('SELECT id FROM products WHERE ord = ? LIMIT 1').get(swapOrd) as { id: number } | null;
596 if (!other) return c.json({ ok: true });
597 getDb().query('UPDATE products SET ord = ? WHERE id = ?').run(swapOrd, id);
598 getDb().query('UPDATE products SET ord = ? WHERE id = ?').run(cur.ord, other.id);
599 return c.json({ ok: true });
600 } catch (e: any) {
601 return c.json({ error: e.message || 'reorder failed' }, 400);
602 }
603 });
604
605 api.post('/admin/upload', async (c) => {
606 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
607 try {
608 const form = await c.req.formData();
609 const file = form.get('file');
610 if (!file || !(file instanceof Blob)) return c.json({ error: 'file required' }, 400);
611 const ext = ((file as any).name || 'image').split('.').pop()?.toLowerCase() || 'jpg';
612 if (!['jpg','jpeg','png','gif','webp','svg'].includes(ext)) return c.json({ error: 'invalid image type' }, 400);
613 const buf = await file.arrayBuffer();
614 const filename = `upload_${Date.now()}_${Math.random().toString(36).slice(2,8)}.${ext}`;
615 const path = `public/images/products/${filename}`;
616 await Bun.write(path, new Uint8Array(buf));
617 return c.json({ url: `/images/products/${filename}` });
618 } catch (e: any) {
619 return c.json({ error: e.message || 'upload failed' }, 500);
620 }
621 });
622
623 // --- content (terms) ---
624 api.get('/terms', (c) => {
625 const { getDb } = require('./lib/db');
626 const rows = getDb().query("SELECT key, value FROM content WHERE key LIKE 'terms_%' OR key LIKE 'privacy_%'").all() as { key: string; value: string }[];
627 const result: Record<string, string> = {};
628 for (const r of rows) result[r.key] = r.value;
629 return c.json(result);
630 });
631
632 api.put('/admin/terms', async (c) => {
633 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
634 try {
635 const body = await c.req.json();
636 const { getDb } = await import('./lib/db');
637 for (const [key, value] of Object.entries(body)) {
638 if (typeof key === 'string' && ['terms_bg', 'terms_en', 'privacy_bg', 'privacy_en'].includes(key) && typeof value === 'string') {
639 getDb().query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(key, value);
640 }
641 }
642 return c.json({ ok: true });
643 } catch {
644 return c.json({ error: 'update failed' }, 400);
645 }
646 });
647
648 api.post('/contact', async (c) => {
649 if (!rateLimit('contact:' + clientKey(c), 5, 300_000)) return c.json({ error: 'too many requests' }, 429);
650 try {
651 const body = await c.req.json();
652 const { name, email, subject, message } = body;
653 if (!name || !email || !message) return c.json({ error: 'name, email and message required' }, 400);
654 const db = (await import('./lib/db')).getDb();
655 db.query('INSERT INTO contact_messages (name, email, subject, message) VALUES (?, ?, ?, ?)').run(
656 String(name).slice(0, 200), String(email).slice(0, 200),
657 String(subject || 'general').slice(0, 50), String(message).slice(0, 5000)
658 );
659 return c.json({ ok: true });
660 } catch {
661 return c.json({ error: 'failed to send' }, 400);
662 }
663 });
664
665 api.get('/admin/messages', async (c) => {
666 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
667 const db = (await import('./lib/db')).getDb();
668 const rows = db.query('SELECT * FROM contact_messages ORDER BY created_at DESC').all();
669 return c.json(rows);
670 });
671
672 api.get('/admin/stripe-config', async (c) => {
673 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
674 const db = (await import('./lib/db')).getDb();
675 const rows = db.query('SELECT key, value FROM content WHERE key LIKE ?').all('stripe_%') as { key: string; value: string }[];
676 const cfg: Record<string, string> = {};
677 for (const r of rows) cfg[r.key] = r.value;
678 return c.json(cfg);
679 });
680
681 api.put('/admin/stripe-config', async (c) => {
682 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
683 try {
684 const body = await c.req.json();
685 const db = (await import('./lib/db')).getDb();
686 const keys = ['stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret'];
687 for (const k of keys) {
688 if (typeof body[k] === 'string') {
689 db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]);
690 }
691 }
692 return c.json({ ok: true });
693 } catch {
694 return c.json({ error: 'update failed' }, 400);
695 }
696 });
697
698 api.get('/admin/btcpay-config', async (c) => {
699 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
700 const db = (await import('./lib/db')).getDb();
701 const rows = db.query('SELECT key, value FROM content WHERE key LIKE ?').all('btcpay_%') as { key: string; value: string }[];
702 const cfg: Record<string, string> = {};
703 for (const r of rows) cfg[r.key] = r.value;
704 return c.json(cfg);
705 });
706
707 api.put('/admin/btcpay-config', async (c) => {
708 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
709 try {
710 const body = await c.req.json();
711 const db = (await import('./lib/db')).getDb();
712 const keys = ['btcpay_url', 'btcpay_api_key', 'btcpay_store_id'];
713 for (const k of keys) {
714 if (typeof body[k] === 'string') {
715 db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]);
716 }
717 }
718 return c.json({ ok: true });
719 } catch {
720 return c.json({ error: 'update failed' }, 400);
721 }
722 });
723
724 api.get('/options', async (c) => {
725 const db = (await import('./lib/db')).getDb();
726 const rows = db.query('SELECT key, value FROM content').all() as { key: string; value: string }[];
727 const cfg: Record<string, string> = {};
728 for (const r of rows) cfg[r.key] = r.value;
729 return c.json(cfg);
730 });
731
732 api.put('/admin/options', async (c) => {
733 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
734 try {
735 const body = await c.req.json();
736 const db = (await import('./lib/db')).getDb();
737 for (const [k, v] of Object.entries(body)) {
738 if (typeof v === 'string') {
739 db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, v);
740 }
741 }
742 return c.json({ ok: true });
743 } catch {
744 return c.json({ error: 'update failed' }, 400);
745 }
746 });
747
748 api.get('/admin/shipping-config', async (c) => {
749 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
750 const db = (await import('./lib/db')).getDb();
751 const rows = db.query("SELECT key, value FROM content WHERE key LIKE 'econt_%' OR key = 'shipping_rate_per_kg' OR key = 'free_shipping_threshold'").all() as { key: string; value: string }[];
752 const cfg: Record<string, string> = {};
753 for (const r of rows) cfg[r.key] = r.value;
754 return c.json(cfg);
755 });
756
757 api.put('/admin/shipping-config', async (c) => {
758 if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
759 try {
760 const body = await c.req.json();
761 const db = (await import('./lib/db')).getDb();
762 const keys = ['econt_username', 'econt_password', 'econt_sender_city', 'econt_sender_postcode', 'econt_sender_street', 'econt_sender_street_num', 'econt_sender_quarter', 'econt_test_mode', 'shipping_rate_per_kg', 'free_shipping_threshold'];
763 for (const k of keys) {
764 if (typeof body[k] === 'string') {
765 db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]);
766 }
767 }
768 return c.json({ ok: true });
769 } catch {
770 return c.json({ error: 'update failed' }, 400);
771 }
772 });
773
774 api.get('/proxy-image', async (c) => {
775 const url = c.req.query('url');
776 if (!url) return c.json({ error: 'url required' }, 400);
777 try {
778 const resp = await fetch(url);
779 if (!resp.ok) return c.json({ error: 'fetch failed' }, 502);
780 const ct = resp.headers.get('content-type') || 'image/jpeg';
781 const buf = await resp.arrayBuffer();
782 return new Response(buf, { headers: { 'Content-Type': ct, 'Cache-Control': 'public, max-age=86400' } });
783 } catch {
784 return c.json({ error: 'fetch failed' }, 502);
785 }
786 });
787
788 export default api;
789