import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { getAllProducts, getProductBySlug, getProductsByCategory, getCategories } from './lib/products'; import { verifyPassword, findOrCreateNostrUser, createSession, getUserFromSession, deleteSession, issueNostrChallenge, verifyNostrAuthEvent, createUser, updateUserPrefs, setUserPassword, } from './lib/auth'; import { getCities, getOfficesByCourier } from './lib/couriers'; import { createOrder, getOrderForView, orderView, setPaymentId, CheckoutError } from './lib/orders'; import { createPayment } from './lib/payments'; const api = new Hono(); api.use('/*', cors({ origin: process.env.SITE_URL || 'http://localhost:3000' })); // --- simple in-memory rate limiter for auth endpoints --- const buckets = new Map(); function rateLimit(key: string, max: number, windowMs: number): boolean { const now = Date.now(); const b = buckets.get(key); if (!b || b.reset < now) { buckets.set(key, { count: 1, reset: now + windowMs }); return true; } b.count++; return b.count <= max; } function clientKey(c: any): string { return c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || 'local'; } // --- catalog --- function stripeConfigKey(key: string): string { const envMap: Record = { stripe_publishable_key: 'STRIPE_PUBLISHABLE_KEY', stripe_secret_key: 'STRIPE_SECRET_KEY', stripe_webhook_secret: 'STRIPE_WEBHOOK_SECRET', btcpay_url: 'BTCPAY_URL', btcpay_api_key: 'BTCPAY_API_KEY', btcpay_store_id: 'BTCPAY_STORE_ID', }; const envVar = envMap[key]; const env = envVar ? process.env[envVar] : undefined; if (env) return env; const db = (() => { try { return require('./lib/db').getDb(); } catch { return null; } })(); if (!db) return ''; const row = db.query('SELECT value FROM content WHERE key = ?').get(key) as { value: string } | null; return row?.value || ''; } api.get('/stripe-key', (c) => c.json({ publishableKey: stripeConfigKey('stripe_publishable_key') || 'pk_mock' })); api.post('/stripe/payment-intent', async (c) => { try { const { items } = await c.req.json(); if (!Array.isArray(items) || items.length === 0) return c.json({ error: 'items required' }, 400); let amount = 0; let weightKg = 0; for (const it of items) { const product = getProductBySlug(String(it?.slug ?? '')); const qty = Number(it?.qty); if (!product || !Number.isInteger(qty) || qty < 1) continue; amount += product.price_cents * qty; weightKg += (product.unit_size * qty) / 1000; } const { shippingCostCentsSync } = await import('./lib/shipping'); const shipping = shippingCostCentsSync(weightKg, amount); const total = amount + shipping; const Stripe = (await import('stripe')).default; const key = stripeConfigKey('stripe_secret_key'); if (!key) { return c.json({ clientSecret: null, amount: total }); } const stripe = new Stripe(key); const intent = await stripe.paymentIntents.create({ amount: total, currency: 'eur', automatic_payment_methods: { enabled: true }, }); return c.json({ clientSecret: intent.client_secret, amount: total }); } catch (e: any) { console.error('payment-intent error:', e); return c.json({ error: 'failed to create intent' }, 400); } }); api.get('/categories', (c) => c.json(getCategories())); api.get('/products', (c) => { const cat = c.req.query('cat'); return c.json(cat ? getProductsByCategory(cat) : getAllProducts()); }); api.get('/products/:slug', (c) => { const product = getProductBySlug(c.req.param('slug')); if (!product) return c.json({ error: 'not found' }, 404); return c.json(product); }); // --- auth --- api.post('/auth/login', async (c) => { if (!rateLimit('login:' + clientKey(c), 10, 60_000)) return c.json({ error: 'too many attempts' }, 429); try { const { email, password } = await c.req.json(); if (!email || !password) return c.json({ error: 'email and password required' }, 400); const userId = verifyPassword(email, password); if (!userId) return c.json({ error: 'invalid email or password' }, 401); const token = createSession(userId); return c.json({ token, user: { id: userId, email } }); } catch { return c.json({ error: 'invalid request body' }, 400); } }); api.post('/auth/register', async (c) => { if (!rateLimit('register:' + clientKey(c), 5, 300_000)) return c.json({ error: 'too many attempts' }, 429); try { const { email, password, name, phone, delivery_prefs } = await c.req.json(); if (!email || !password) return c.json({ error: 'email and password required' }, 400); if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { return c.json({ error: 'valid email required' }, 400); } if (typeof password !== 'string' || password.length < 6) { return c.json({ error: 'password must be at least 6 characters' }, 400); } const { getDb } = await import('./lib/db'); const existing = getDb().query('SELECT id FROM users WHERE email = ?').get(email); if (existing) return c.json({ error: 'email already registered' }, 409); const userId = createUser(email, password); if (name || phone || delivery_prefs) { updateUserPrefs(userId, { name, phone, delivery_prefs: delivery_prefs ? JSON.stringify(delivery_prefs) : undefined }); } const token = createSession(userId); return c.json({ token, user: { id: userId, email } }); } catch { return c.json({ error: 'invalid request body' }, 400); } }); api.get('/auth/nostr-challenge', (c) => { if (!rateLimit('challenge:' + clientKey(c), 30, 60_000)) return c.json({ error: 'too many attempts' }, 429); return c.json({ challenge: issueNostrChallenge() }); }); api.post('/auth/nostr', async (c) => { if (!rateLimit('nostr:' + clientKey(c), 10, 60_000)) return c.json({ error: 'too many attempts' }, 429); try { const { event } = await c.req.json(); const pubkey = verifyNostrAuthEvent(event); if (!pubkey) return c.json({ error: 'invalid auth event' }, 401); const { userId, created } = findOrCreateNostrUser(pubkey); const token = createSession(userId); return c.json({ token, user: { id: userId, pubkey }, new_user: created }); } catch { return c.json({ error: 'invalid request body' }, 400); } }); api.get('/auth/session', (c) => { const auth = c.req.header('Authorization'); if (!auth?.startsWith('Bearer ')) return c.json({ user: null }); const user = getUserFromSession(auth.slice(7)); 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 }); }); // --- user prefs --- function authUser(c: any): any | null { const auth = c.req.header('Authorization'); if (!auth?.startsWith('Bearer ')) return null; return getUserFromSession(auth.slice(7)); } api.put('/user/prefs', async (c) => { const user = authUser(c); if (!user) return c.json({ error: 'unauthorized' }, 401); let body: any; try { body = await c.req.json(); const updated = updateUserPrefs(user.id, { name: body.name, phone: body.phone, email: body.email, delivery_prefs: body.delivery_prefs ? JSON.stringify(body.delivery_prefs) : undefined, }); 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 }); } catch (e: any) { const msg = e?.message || String(e); console.error('user/prefs error:', msg, 'userId:', user?.id, 'bodyKeys:', Object.keys(body || {})); if (msg.includes('UNIQUE')) return c.json({ error: 'email address in use' }, 409); return c.json({ error: msg || 'invalid request body' }, 400); } }); api.put('/user/password', async (c) => { const user = authUser(c); if (!user) return c.json({ error: 'unauthorized' }, 401); const { password } = await c.req.json(); if (!password || password.length < 6) return c.json({ error: 'password must be at least 6 characters' }, 400); const ok = setUserPassword(user.id, password); return c.json({ ok }); }); // --- user orders --- api.get('/user/orders', (c) => { const user = authUser(c); if (!user) return c.json({ error: 'unauthorized' }, 401); const { getDb } = require('./lib/db'); const rows = getDb().query( 'SELECT id, total_cents, status, payment_method, payment_status, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 100' ).all(user.id); return c.json(rows); }); api.get('/user/orders/:id', (c) => { const user = authUser(c); if (!user) return c.json({ error: 'unauthorized' }, 401); const { getDb } = require('./lib/db'); const order = getDb().query( 'SELECT * FROM orders WHERE id = ? AND user_id = ?' ).get(c.req.param('id'), user.id) as any; if (!order) return c.json({ error: 'not found' }, 404); const { orderView } = require('./lib/orders'); 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 }); }); api.post('/user/orders/:id/cancel', async (c) => { const user = authUser(c); if (!user) return c.json({ error: 'unauthorized' }, 401); try { const { getOrder, transitionOrder } = await import('./lib/orders'); const order = getOrder(c.req.param('id')); if (!order) return c.json({ error: 'not found' }, 404); if (order.user_id !== user.id) return c.json({ error: 'forbidden' }, 403); if (order.status !== 'pending') return c.json({ error: 'only pending orders can be cancelled' }, 400); transitionOrder(order.id, 'cancelled'); return c.json({ ok: true }); } catch (e: any) { return c.json({ error: e.message || 'cancel failed' }, 400); } }); function adminUser(c: any): any | null { const user = authUser(c); return user?.is_admin ? user : null; } api.get('/admin/users', (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const { getDb } = require('./lib/db'); const rows = getDb().query( `SELECT u.id, u.email, u.nostr_pubkey, u.is_admin, u.name, u.delivery_prefs, u.created_at, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count FROM users u ORDER BY u.created_at DESC LIMIT 200` ).all(); return c.json(rows); }); api.post('/admin/users', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const { email, password, is_admin } = await c.req.json(); if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { return c.json({ error: 'valid email required' }, 400); } if (typeof password !== 'string' || password.length < 6) { return c.json({ error: 'password must be at least 6 characters' }, 400); } const { getDb } = require('./lib/db'); const existing = getDb().query('SELECT id FROM users WHERE email = ?').get(email); if (existing) return c.json({ error: 'email already registered' }, 409); const userId = createUser(email, password); if (is_admin) { getDb().query('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId); } return c.json({ ok: true, id: userId }); } catch { return c.json({ error: 'create failed' }, 400); } }); api.post('/admin/users/:id/admin', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const { is_admin } = await c.req.json(); if (typeof is_admin !== 'number' || (is_admin !== 0 && is_admin !== 1)) { return c.json({ error: 'is_admin must be 0 or 1' }, 400); } const { getDb } = require('./lib/db'); getDb().query('UPDATE users SET is_admin = ? WHERE id = ?').run(is_admin, c.req.param('id')); return c.json({ ok: true }); } catch { return c.json({ error: 'bad request' }, 400); } }); api.get('/admin/users/:id/orders', (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const { getDb } = require('./lib/db'); const rows = getDb().query( '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' ).all(c.req.param('id')); return c.json(rows); }); api.post('/auth/logout', (c) => { const auth = c.req.header('Authorization'); if (auth?.startsWith('Bearer ')) deleteSession(auth.slice(7)); return c.json({ ok: true }); }); // --- checkout --- api.post('/checkout', async (c) => { try { const body = await c.req.json(); const auth = c.req.header('Authorization'); if (auth?.startsWith('Bearer ')) { const user = getUserFromSession(auth.slice(7)); if (user) body.user_id = user.id; } const order = await createOrder(body); let payment: any = null; if (body.paymentIntentId) { // express checkout paid already - mark as paid const { getDb } = require('./lib/db'); getDb().query('UPDATE orders SET payment_status = ?, payment_id = ? WHERE id = ?').run('paid', body.paymentIntentId, order.id); payment = { type: body.paymentMethod, paymentId: body.paymentIntentId }; } else { try { payment = await createPayment(order); if (payment?.paymentId) setPaymentId(order.id, payment.paymentId); } catch (e: any) { payment = { type: order.payment_method, error: e.message || 'payment setup failed' }; } } // fire best-effort confirmation email const { orderCreatedEmail } = await import('./lib/email'); const items = JSON.parse(order.items); const desc = items.map((i: any) => `${i.name} × ${i.qty}кг`).join(', '); orderCreatedEmail(order.customer_email, order.id, order.view_token, ((order.total_cents) / 100).toFixed(2) + ' лв', desc); return c.json({ orderId: order.id, viewToken: order.view_token, status: order.status, total_cents: order.total_cents, shipping_cents: order.shipping_cents, payment, }); } catch (e: any) { if (e.status && e.message) return c.json({ error: e.message }, e.status as any); console.error('checkout error:', e); return c.json({ error: 'checkout failed' }, 500); } }); api.get('/orders/:id', async (c) => { let order = getOrderForView(c.req.param('id'), c.req.query('t') || ''); if (!order) return c.json({ error: 'not found' }, 404); // lightning settles via LUD-21 verify polling - check on read while pending if (order.status === 'pending' && order.payment_method === 'lightning' && order.payment_id) { const { checkLightningPaid } = await import('./lib/payments'); if (await checkLightningPaid(order.payment_id)) { const { transitionOrder } = await import('./lib/orders'); order = transitionOrder(order.id, 'paid'); } } return c.json(orderView(order)); }); // shipping preview for the checkout page - server-priced, same path as createOrder api.post('/shipping-quote', async (c) => { try { const { items, city, courier, cod, officeDelivery } = await c.req.json(); if (!Array.isArray(items)) return c.json({ error: 'items required' }, 400); let subtotal = 0; let weightKg = 0; for (const it of items) { const product = getProductBySlug(String(it?.slug ?? '')); const qty = Number(it?.qty); if (!product || !Number.isInteger(qty) || qty < 1) continue; subtotal += product.price_cents * qty; weightKg += (product.unit_size * qty) / 1000; } const { shippingCostCents } = await import('./lib/shipping'); const shipping = await shippingCostCents(weightKg, subtotal, city || undefined, courier || undefined, cod ? subtotal : 0, officeDelivery || false); return c.json({ subtotal_cents: subtotal, shipping_cents: shipping, total_cents: subtotal + shipping }); } catch (e: any) { console.error('shipping-quote error:', e); return c.json({ error: 'invalid request body' }, 400); } }); // --- payment webhooks (public path: /api/webhooks/... via caddy) --- api.post('/webhooks/stripe', async (c) => { const secret = process.env.STRIPE_WEBHOOK_SECRET; const key = process.env.STRIPE_SECRET_KEY; if (!secret || !key) return c.json({ error: 'not configured' }, 501); const sig = c.req.header('stripe-signature'); if (!sig) return c.json({ error: 'missing signature' }, 400); try { const Stripe = (await import('stripe')).default; const stripe = new Stripe(key); const body = await c.req.text(); const event = await stripe.webhooks.constructEventAsync(body, sig, secret); if (event.type === 'payment_intent.succeeded') { const intent = event.data.object as any; const { markPaidByPaymentId, getOrder } = await import('./lib/orders'); const paid = markPaidByPaymentId(intent.id); if (paid) { const { paymentConfirmedEmail } = await import('./lib/email'); paymentConfirmedEmail(paid.customer_email, paid.id); } } else if (event.type === 'payment_intent.payment_failed') { const intent = event.data.object as any; const { getDb } = await import('./lib/db'); const order = getDb().query('SELECT id, status FROM orders WHERE payment_id = ?').get(intent.id) as any; if (order?.status === 'pending') { const { transitionOrder } = await import('./lib/orders'); transitionOrder(order.id, 'failed'); } } return c.json({ received: true }); } catch (e: any) { return c.json({ error: 'signature verification failed' }, 400); } }); api.post('/webhooks/btcpay', async (c) => { const secret = process.env.BTCPAY_WEBHOOK_SECRET; if (!secret) return c.json({ error: 'not configured' }, 501); const sig = c.req.header('btcpay-sig'); if (!sig) return c.json({ error: 'missing signature' }, 400); const body = await c.req.text(); const crypto = await import('crypto'); const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex'); const sigBuf = Buffer.from(sig); const expBuf = Buffer.from(expected); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { return c.json({ error: 'signature verification failed' }, 400); } try { const event = JSON.parse(body); const orderId = event.metadata?.orderId; if (orderId && (event.type === 'InvoiceSettled' || event.type === 'InvoicePaymentSettled')) { const { getOrder, transitionOrder } = await import('./lib/orders'); const order = getOrder(orderId); if (order && order.status === 'pending') { transitionOrder(orderId, 'paid'); const { paymentConfirmedEmail } = await import('./lib/email'); paymentConfirmedEmail(order.customer_email, orderId); } } else if (orderId && event.type === 'InvoiceExpired') { const { getOrder, transitionOrder } = await import('./lib/orders'); const order = getOrder(orderId); if (order?.status === 'pending') transitionOrder(orderId, 'expired'); } return c.json({ received: true }); } catch { return c.json({ error: 'bad payload' }, 400); } }); // --- couriers --- api.get('/cities', (c) => c.json(getCities())); api.get('/offices', (c) => { const city = c.req.query('city'); const courier = c.req.query('courier'); let offices = getOfficesByCourier(courier || ''); if (city) offices = offices.filter(o => o.city.toLowerCase().includes(city.toLowerCase())); return c.json(offices); }); // --- admin orders --- api.get('/admin/orders', (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const { getDb } = require('./lib/db'); const orders = getDb().query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 200').all(); return c.json(orders); }); api.post('/admin/orders/:id/status', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const { to } = await c.req.json(); const { transitionOrder } = await import('./lib/orders'); const order = transitionOrder(c.req.param('id'), to); if (to === 'paid') { const { paymentConfirmedEmail } = await import('./lib/email'); paymentConfirmedEmail(order.customer_email, order.id); } return c.json(order); } catch (e: any) { if (e.status && e.message) return c.json({ error: e.message }, e.status as any); return c.json({ error: 'transition failed' }, 500); } }); api.post('/admin/orders/:id/label', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const { getOrder } = await import('./lib/orders'); const order = getOrder(c.req.param('id')); if (!order) return c.json({ error: 'order not found' }, 404); if (order.status !== 'paid' && order.status !== 'pending') return c.json({ error: 'order must be paid or pending' }, 400); const items: any[] = JSON.parse(order.items); const addr: any = JSON.parse(order.shipping_address || '{}'); const weightKg = items.reduce((s: number, it: any) => s + (it.unit_size * it.qty) / 1000, 0); const { econtCreateLabel } = await import('./lib/econt'); const result = await econtCreateLabel( order.customer_name, order.customer_phone || '', addr.city || '', addr.address || '', addr.postCode || '', addr.address2 || '', weightKg, order.shipping_method === 'office', order.payment_method === 'cod' ? order.total_cents : 0, ); if (result.error) return c.json({ error: result.error }, 400); const { getDb } = await import('./lib/db'); getDb().query("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('shipped', order.id); getDb().query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(`label_${order.id}`, JSON.stringify(result)); return c.json(result); } catch (e: any) { console.error('label creation error:', e); return c.json({ error: e.message || 'label creation failed' }, 400); } }); api.post('/admin/products', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const p = await c.req.json(); if (!p.name || !p.slug || !Number.isInteger(p.price_cents) || p.price_cents < 0) { return c.json({ error: 'name, slug, price_cents required' }, 400); } const { getDb } = await import('./lib/db'); const maxOrd = (getDb().query('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM products').get() as { n: number }).n; getDb().query(`INSERT INTO products (name, name_en, slug, description, description_en, price_cents, unit_size, unit, images, category, stock_count, harvest_year, ord) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( p.name, p.name_en || '', p.slug, p.description || '', p.description_en || '', p.price_cents, p.unit_size ?? 100, p.unit || 'g', p.images || '[]', p.category || 'bilki', p.stock_count ?? 0, p.harvest_year || null, maxOrd); return c.json(getProductBySlug(p.slug)); } catch (e: any) { return c.json({ error: e.message?.includes('UNIQUE') ? 'slug already exists' : 'create failed' }, 400); } }); api.put('/admin/products/:id', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); let p: any = {}; try { p = await c.req.json(); const id = parseInt(c.req.param('id')); const { getDb } = await import('./lib/db'); const existing = getDb().query('SELECT id FROM products WHERE id = ?').get(id); if (!existing) return c.json({ error: 'not found' }, 404); getDb().query(`UPDATE products SET name = ?, name_en = ?, slug = ?, description = ?, description_en = ?, price_cents = ?, unit_size = ?, unit = ?, images = ?, category = ?, stock_count = ?, harvest_year = ?, usage = ?, usage_en = ? WHERE id = ?`).run( p.name, p.name_en || '', p.slug, p.description || '', p.description_en || '', 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, p.usage || '', p.usage_en || '', id); return c.json(getDb().query('SELECT * FROM products WHERE id = ?').get(id)); } catch (e: any) { console.error('admin/products PUT failed:', e?.message || e, 'body:', JSON.stringify(p || {})); return c.json({ error: 'update failed' }, 400); } }); api.delete('/admin/products/:id', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const { getDb } = await import('./lib/db'); getDb().query('DELETE FROM products WHERE id = ?').run(parseInt(c.req.param('id'))); return c.json({ ok: true }); }); api.post('/admin/products/:id/reorder', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const { direction } = await c.req.json(); if (direction !== 'up' && direction !== 'down') return c.json({ error: 'invalid direction' }, 400); const id = parseInt(c.req.param('id')); const { getDb } = await import('./lib/db'); const cur = getDb().query('SELECT ord FROM products WHERE id = ?').get(id) as { ord: number } | null; if (!cur) return c.json({ error: 'not found' }, 404); const swapOrd = direction === 'up' ? cur.ord - 1 : cur.ord + 1; const other = getDb().query('SELECT id FROM products WHERE ord = ? LIMIT 1').get(swapOrd) as { id: number } | null; if (!other) return c.json({ ok: true }); getDb().query('UPDATE products SET ord = ? WHERE id = ?').run(swapOrd, id); getDb().query('UPDATE products SET ord = ? WHERE id = ?').run(cur.ord, other.id); return c.json({ ok: true }); } catch (e: any) { return c.json({ error: e.message || 'reorder failed' }, 400); } }); api.post('/admin/upload', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const form = await c.req.formData(); const file = form.get('file'); if (!file || !(file instanceof Blob)) return c.json({ error: 'file required' }, 400); const ext = ((file as any).name || 'image').split('.').pop()?.toLowerCase() || 'jpg'; if (!['jpg','jpeg','png','gif','webp','svg'].includes(ext)) return c.json({ error: 'invalid image type' }, 400); const buf = await file.arrayBuffer(); const filename = `upload_${Date.now()}_${Math.random().toString(36).slice(2,8)}.${ext}`; const path = `public/images/products/${filename}`; await Bun.write(path, new Uint8Array(buf)); return c.json({ url: `/images/products/${filename}` }); } catch (e: any) { return c.json({ error: e.message || 'upload failed' }, 500); } }); // --- content (terms) --- api.get('/terms', (c) => { const { getDb } = require('./lib/db'); const rows = getDb().query("SELECT key, value FROM content WHERE key LIKE 'terms_%' OR key LIKE 'privacy_%'").all() as { key: string; value: string }[]; const result: Record = {}; for (const r of rows) result[r.key] = r.value; return c.json(result); }); api.put('/admin/terms', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const body = await c.req.json(); const { getDb } = await import('./lib/db'); for (const [key, value] of Object.entries(body)) { if (typeof key === 'string' && ['terms_bg', 'terms_en', 'privacy_bg', 'privacy_en'].includes(key) && typeof value === 'string') { getDb().query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(key, value); } } return c.json({ ok: true }); } catch { return c.json({ error: 'update failed' }, 400); } }); api.post('/contact', async (c) => { if (!rateLimit('contact:' + clientKey(c), 5, 300_000)) return c.json({ error: 'too many requests' }, 429); try { const body = await c.req.json(); const { name, email, subject, message } = body; if (!name || !email || !message) return c.json({ error: 'name, email and message required' }, 400); const db = (await import('./lib/db')).getDb(); db.query('INSERT INTO contact_messages (name, email, subject, message) VALUES (?, ?, ?, ?)').run( String(name).slice(0, 200), String(email).slice(0, 200), String(subject || 'general').slice(0, 50), String(message).slice(0, 5000) ); return c.json({ ok: true }); } catch { return c.json({ error: 'failed to send' }, 400); } }); api.get('/admin/messages', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const db = (await import('./lib/db')).getDb(); const rows = db.query('SELECT * FROM contact_messages ORDER BY created_at DESC').all(); return c.json(rows); }); api.get('/admin/stripe-config', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const db = (await import('./lib/db')).getDb(); const rows = db.query('SELECT key, value FROM content WHERE key LIKE ?').all('stripe_%') as { key: string; value: string }[]; const cfg: Record = {}; for (const r of rows) cfg[r.key] = r.value; return c.json(cfg); }); api.put('/admin/stripe-config', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const body = await c.req.json(); const db = (await import('./lib/db')).getDb(); const keys = ['stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret']; for (const k of keys) { if (typeof body[k] === 'string') { db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]); } } return c.json({ ok: true }); } catch { return c.json({ error: 'update failed' }, 400); } }); api.get('/admin/btcpay-config', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const db = (await import('./lib/db')).getDb(); const rows = db.query('SELECT key, value FROM content WHERE key LIKE ?').all('btcpay_%') as { key: string; value: string }[]; const cfg: Record = {}; for (const r of rows) cfg[r.key] = r.value; return c.json(cfg); }); api.put('/admin/btcpay-config', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const body = await c.req.json(); const db = (await import('./lib/db')).getDb(); const keys = ['btcpay_url', 'btcpay_api_key', 'btcpay_store_id']; for (const k of keys) { if (typeof body[k] === 'string') { db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]); } } return c.json({ ok: true }); } catch { return c.json({ error: 'update failed' }, 400); } }); api.get('/options', async (c) => { const db = (await import('./lib/db')).getDb(); const rows = db.query('SELECT key, value FROM content').all() as { key: string; value: string }[]; const cfg: Record = {}; for (const r of rows) cfg[r.key] = r.value; return c.json(cfg); }); api.put('/admin/options', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const body = await c.req.json(); const db = (await import('./lib/db')).getDb(); for (const [k, v] of Object.entries(body)) { if (typeof v === 'string') { db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, v); } } return c.json({ ok: true }); } catch { return c.json({ error: 'update failed' }, 400); } }); api.get('/admin/shipping-config', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); const db = (await import('./lib/db')).getDb(); 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 }[]; const cfg: Record = {}; for (const r of rows) cfg[r.key] = r.value; return c.json(cfg); }); api.put('/admin/shipping-config', async (c) => { if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403); try { const body = await c.req.json(); const db = (await import('./lib/db')).getDb(); 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']; for (const k of keys) { if (typeof body[k] === 'string') { db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]); } } return c.json({ ok: true }); } catch { return c.json({ error: 'update failed' }, 400); } }); api.get('/proxy-image', async (c) => { const url = c.req.query('url'); if (!url) return c.json({ error: 'url required' }, 400); try { const resp = await fetch(url); if (!resp.ok) return c.json({ error: 'fetch failed' }, 502); const ct = resp.headers.get('content-type') || 'image/jpeg'; const buf = await resp.arrayBuffer(); return new Response(buf, { headers: { 'Content-Type': ct, 'Cache-Control': 'public, max-age=86400' } }); } catch { return c.json({ error: 'fetch failed' }, 502); } }); export default api;