From 49bf370d41f550abd89fcb2f1531b79df6fbb2d5 Mon Sep 17 00:00:00 2001 From: muken Date: Sat, 27 Jun 2026 20:41:41 -0500 Subject: [PATCH] Prod hardening and K8s drafts: auth fail-close, DB pool, init/schema split, prod init guard --- k8s/app.yaml | 94 ++++++++---------- k8s/deployment.json | 83 ++++++++++++++++ k8s/ingress.json | 43 ++++++++ k8s/service.json | 21 ++++ src/app/admin/page.tsx | 184 +++++++++++++++-------------------- src/app/api/db/init/route.ts | 7 +- src/lib/auth.ts | 5 +- src/lib/db.ts | 10 +- src/lib/schema.ts | 156 +++++++++++++---------------- 9 files changed, 346 insertions(+), 257 deletions(-) create mode 100644 k8s/deployment.json create mode 100644 k8s/ingress.json create mode 100644 k8s/service.json diff --git a/k8s/app.yaml b/k8s/app.yaml index ad41594..7c5b76b 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -1,105 +1,89 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: ${NAMESPACE} ---- apiVersion: apps/v1 kind: Deployment metadata: - name: la-huasca-nextjs - namespace: ${NAMESPACE} + name: la-huasca-ts + namespace: la-huasca-ts spec: - replicas: 2 - strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 1 - maxUnavailable: 0 + replicas: 1 selector: matchLabels: - app: la-huasca-nextjs + app: la-huasca-ts template: metadata: labels: - app: la-huasca-nextjs + app: la-huasca-ts spec: imagePullSecrets: - name: la-huasca-registry containers: - - name: nextjs - image: gitea.ajavasoft.com/muken/la-huasca-ts:${TAG} + - name: la-huasca-ts + image: gitea.ajavasoft.com/muken/la-huasca-ts:latest ports: - containerPort: 3000 + name: http env: + - name: NODE_ENV + value: production - name: DATABASE_URL valueFrom: secretKeyRef: - name: la-huasca-db-secrets - key: DATABASE_URL - - name: JWT_SECRET - valueFrom: - secretKeyRef: - name: la-huasca-app-secrets - key: JWT_SECRET + name: calendar-db-secrets + key: url readinessProbe: httpGet: - path: / - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 5 + path: /api/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 livenessProbe: httpGet: - path: / - port: 3000 - initialDelaySeconds: 15 - periodSeconds: 10 + path: /api/health + port: http + initialDelaySeconds: 60 + periodSeconds: 30 resources: requests: - memory: "256Mi" - cpu: "100m" + cpu: 200m + memory: 256Mi limits: - memory: "512Mi" - cpu: "500m" + cpu: 500m + memory: 512Mi --- apiVersion: v1 kind: Service metadata: - name: la-huasca-nextjs - namespace: ${NAMESPACE} + name: la-huasca-ts + namespace: la-huasca-ts spec: - selector: - app: la-huasca-nextjs ports: - port: 80 - targetPort: 3000 + targetPort: http + protocol: TCP + name: http + selector: + app: la-huasca-ts --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: la-huasca-nextjs - namespace: ${NAMESPACE} + name: la-huasca-ts + namespace: la-huasca-ts annotations: traefik.ingress.kubernetes.io/router.entrypoints: web,websecure spec: ingressClassName: traefik + tls: + - hosts: + - la-huasca.ajavasoft.com + secretName: la-huasca-ts-tls rules: - - host: lahuasca.com + - host: la-huasca.ajavasoft.com http: paths: - path: / pathType: Prefix backend: service: - name: la-huasca-nextjs - port: - number: 80 - - host: www.lahuasca.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: la-huasca-nextjs + name: la-huasca-ts port: number: 80 diff --git a/k8s/deployment.json b/k8s/deployment.json new file mode 100644 index 0000000..6aef13e --- /dev/null +++ b/k8s/deployment.json @@ -0,0 +1,83 @@ +{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "la-huasca-ts", + "namespace": "la-huasca-ts" + }, + "spec": { + "replicas": 1, + "selector": { + "matchLabels": { + "app": "la-huasca-ts" + } + }, + "template": { + "metadata": { + "labels": { + "app": "la-huasca-ts" + } + }, + "spec": { + "imagePullSecrets": [ + { + "name": "la-huasca-registry" + } + ], + "containers": [ + { + "name": "la-huasca-ts", + "image": "gitea.ajavasoft.com/muken/la-huasca-ts:latest", + "ports": [ + { + "containerPort": 3000, + "name": "http" + } + ], + "env": [ + { + "name": "NODE_ENV", + "value": "production" + }, + { + "name": "DATABASE_URL", + "valueFrom": { + "secretKeyRef": { + "name": "calendar-db-secrets", + "key": "url" + } + } + } + ], + "readinessProbe": { + "httpGet": { + "path": "/api/health", + "port": "http" + }, + "initialDelaySeconds": 20, + "periodSeconds": 15 + }, + "livenessProbe": { + "httpGet": { + "path": "/api/health", + "port": "http" + }, + "initialDelaySeconds": 60, + "periodSeconds": 30 + }, + "resources": { + "requests": { + "cpu": "200m", + "memory": "256Mi" + }, + "limits": { + "cpu": "500m", + "memory": "512Mi" + } + } + } + ] + } + } + } +} diff --git a/k8s/ingress.json b/k8s/ingress.json new file mode 100644 index 0000000..08851b5 --- /dev/null +++ b/k8s/ingress.json @@ -0,0 +1,43 @@ +{ + "apiVersion": "networking.k8s.io/v1", + "kind": "Ingress", + "metadata": { + "name": "la-huasca-ts", + "namespace": "la-huasca-ts", + "annotations": { + "traefik.ingress.kubernetes.io/router.entrypoints": "web,websecure" + } + }, + "spec": { + "ingressClassName": "traefik", + "tls": [ + { + "hosts": [ + "lahuasca.com" + ], + "secretName": "la-huasca-ts-tls" + } + ], + "rules": [ + { + "host": "lahuasca.com", + "http": { + "paths": [ + { + "path": "/", + "pathType": "Prefix", + "backend": { + "service": { + "name": "la-huasca-ts", + "port": { + "number": 80 + } + } + } + } + ] + } + } + ] + } +} diff --git a/k8s/service.json b/k8s/service.json new file mode 100644 index 0000000..e77e33b --- /dev/null +++ b/k8s/service.json @@ -0,0 +1,21 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "la-huasca-ts", + "namespace": "la-huasca-ts" + }, + "spec": { + "ports": [ + { + "port": 80, + "targetPort": "http", + "protocol": "TCP", + "name": "http" + } + ], + "selector": { + "app": "la-huasca-ts" + } + } +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a78444d..fc9bbb5 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -329,14 +329,20 @@ export default function AdminPage() { const savePlace = async (e: React.FormEvent) => { e.preventDefault(); - const payload = { ...placeForm, photos: placeForm.photos || [], featured_photo: placeForm.featured_photo || null, active: placeForm.active !== false, sort_order: placeForm.sort_order || 0 }; + const payload = { + ...placeForm, + photos: Array.isArray(placeForm.photos) ? placeForm.photos : [], + featured_photo: placeForm.featured_photo || null, + active: placeForm.active === true, + sort_order: placeForm.sort_order || 0, + }; if (editingPlace) { - await api(`/api/places/${editingPlace}`, 'PUT', payload); - setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...payload } as Place : p))); + const r = await api(`/api/places/${editingPlace}`, 'PUT', payload); + setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? ({ ...p, ...payload } as Place) : p))); setEditingPlace(null); } else { - const json = await api('/api/places', 'POST', payload); - setPlaces((prev) => [...prev, json.data]); + const r = await api('/api/places', 'POST', payload); + setPlaces((prev) => [...prev, r.data]); } setPlaceForm({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 }); notify('Place saved.'); @@ -490,7 +496,7 @@ export default function AdminPage() { {error &&

{error}

} {message &&

{message}

} -
+ <AccordionSection title="Brand Assets">
@@ -504,7 +510,7 @@ export default function AdminPage() {

-
+ <AccordionSection title="Footer / Contact">
setFooter({ ...footer, facebook_url: v })} /> setFooter({ ...footer, instagram_url: v })} /> @@ -518,7 +524,7 @@ export default function AdminPage() {
-
+ <AccordionSection title="Image Gallery">
-
+ <AccordionSection title="Homepage Slideshow">
setSlideForm({ ...slideForm, title: v })} /> setSlideForm({ ...slideForm, subtitle: v })} /> @@ -583,7 +589,7 @@ export default function AdminPage() { [s.title || '(untitled)', s.image_url ? '🖼️ image' : 'no image', s.active ? 'On' : 'Off', `#${s.sort_order}`])} onEdit={(i) => editSlide(slides[i])} onDelete={(i) => deleteSlide(slides[i].id)} />
-
+ <AccordionSection title="Calendar Events"> setEventForm({ ...eventForm, date: v })} /> setEventForm({ ...eventForm, title: v })} /> @@ -621,7 +627,7 @@ export default function AdminPage() { [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
-
+ <AccordionSection title="Social Events"> setSocialEventForm({ ...socialEventForm, name: v })} /> setSocialEventForm({ ...socialEventForm, price: v })} /> @@ -686,7 +692,7 @@ export default function AdminPage() { )}
-
+ <AccordionSection title="Rooms"> setRoomForm({ ...roomForm, name: v })} /> setRoomForm({ ...roomForm, price: v })} /> @@ -741,7 +747,7 @@ export default function AdminPage() { )}
-
+ <AccordionSection title="Menu"> setMenuForm({ ...menuForm, category: v })} /> setMenuForm({ ...menuForm, name: v })} /> @@ -764,7 +770,7 @@ export default function AdminPage() { [m.category, m.name, `$${m.price}`, m.is_daily_special ? '★ Daily' : '', m.active ? 'On' : 'Off', `#${m.sort_order}`])} onEdit={(i) => editMenu(menu[i])} onDelete={(i) => deleteMenu(menu[i].id)} />
-
+ <AccordionSection title="Landscape Places"> setPlaceForm({ ...placeForm, name: v })} /> setPlaceForm({ ...placeForm, distance_km: v })} /> @@ -791,7 +797,7 @@ export default function AdminPage() { [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.photos.length > 0 ? `🖼️ ${p.photos.length}` : 'no image', p.active ? 'On' : 'Off', `#${p.sort_order}`])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} />
-
+ <AccordionSection title="Pending Reviews"> {reviews.length === 0 ?

No pending reviews.

: (
{reviews.map((r) => ( @@ -807,101 +813,65 @@ export default function AdminPage() {
))} - - )} -
+ function AccordionSection({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+
setOpen((v) => !v)}> +

{title}

+ {open ? 'Minimize' : 'Maximize'} +
+ {open &&
{children}
} +
+ ); + } -
- - setUserForm({ ...userForm, username: v })} /> - setUserForm({ ...userForm, password: v })} /> - -
- -
- - [u.username, u.role, u.first_name || '', u.last_name || '', u.comments_disabled ? 'comments disabled' : ''])} onDelete={(i) => deleteUser(users[i].id)} onEdit={(i) => { setEditingUser(users[i].id); setUserEditForm({ first_name: users[i].first_name || '', last_name: users[i].last_name || '', comments_disabled: users[i].comments_disabled }); }} /> - {editingUser !== null && ( -
- Edit user {users.find((u) => u.id === editingUser)?.username} - setUserEditForm({ ...userEditForm, first_name: v })} /> - setUserEditForm({ ...userEditForm, last_name: v })} /> - -
- - setEditingUser(null)}>Cancel -
-
- )} -
- - ); -} + function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) { + return ( + + ); + } -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} + function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) { + return ( + + ); + } -function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) { - return ( - - ); -} - -function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) { - return ( - - ); -} - -function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { - return ( + function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { + return (