diff --git a/.env.example b/.env.example index 7190dcf..3e9abd6 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,12 @@ AUTH_SECRET="changeme" # URL publique du site, utilisée pour résoudre les URLs canoniques et # OpenGraph (SEO). En développement, laissez la valeur par défaut. NEXT_PUBLIC_SITE_URL="http://localhost:3000" +APP_URL="http://localhost:3000" + +# Stripe (mode test recommandé pour le MVP) +STRIPE_SECRET_KEY="sk_test_xxx" +STRIPE_WEBHOOK_SECRET="whsec_xxx" +STRIPE_OWNER_SUBSCRIPTION_PRICE_ID="price_xxx" # Stockage objet des médias (S3 ou MinIO). Compatible AWS S3 et MinIO. # Pour MinIO en local : renseignez S3_ENDPOINT (ex: http://localhost:9000) diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..6666bac --- /dev/null +++ b/.env.production.example @@ -0,0 +1,31 @@ +NODE_ENV=production +PORT=3000 + +# Domain +NEXT_PUBLIC_SITE_URL=https://karbe.cosmolan.fr +APP_URL=https://karbe.cosmolan.fr + +# Security +NEXTAUTH_SECRET=replace_with_random_secret +AUTH_SECRET=replace_with_random_secret + +# Database (managed PostgreSQL recommended) +DATABASE_URL=postgresql://user:password@db-host:5432/karbe?schema=public + +# Stripe TEST +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_OWNER_SUBSCRIPTION_PRICE_ID=price_xxx + +# Storage S3 / MinIO +S3_ENDPOINT=https://s3.example.com +S3_REGION=eu-west-3 +S3_BUCKET=karbe-medias +S3_ACCESS_KEY_ID=replace_me +S3_SECRET_ACCESS_KEY=replace_me +S3_PUBLIC_URL=https://cdn.example.com/karbe-medias +S3_FORCE_PATH_STYLE=false + +# Recommended for stable multi-instance deploys +NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replace_with_base64_32_bytes +DEPLOYMENT_VERSION=manual-v1 diff --git a/.gitignore b/.gitignore index 1df4829..e958edb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +!.env.production.example # vercel .vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b406e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:20-alpine AS base +WORKDIR /app + +FROM base AS deps +COPY package.json package-lock.json ./ +RUN npm ci + +FROM base AS builder +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +RUN addgroup -S nextjs && adduser -S nextjs -G nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 61b8977..756894a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ et payer leur séjour en ligne. - [Prérequis](#prérequis) - [Installation](#installation) - [Variables d'environnement](#variables-denvironnement) +- [Déploiement production (karbe.cosmolan.fr)](#déploiement-production-karbecosmolanfr) - [Développement](#développement) - [Base de données (Prisma)](#base-de-données-prisma) - [Scripts npm](#scripts-npm) @@ -115,11 +116,70 @@ Copiez-le en `.env` et renseignez vos valeurs. | --- | --- | | `DATABASE_URL` | Chaîne de connexion PostgreSQL utilisée par Prisma. | | `AUTH_SECRET` / `NEXTAUTH_SECRET` | Secret de signature des sessions NextAuth. Générer avec `openssl rand -base64 32`. | +| `NEXT_PUBLIC_SITE_URL` / `APP_URL` | URL publique de l'application (ex: `https://karbe.cosmolan.fr`). | +| `STRIPE_SECRET_KEY` | Clé secrète Stripe (mode test pour MVP). | +| `STRIPE_WEBHOOK_SECRET` | Secret du webhook Stripe. | +| `STRIPE_OWNER_SUBSCRIPTION_PRICE_ID` | Price ID de l'abonnement loueur Stripe. | > Le fichier `.env` ne doit **jamais** être commité (il est ignoré par Git). > Au fur et à mesure de l'ajout des intégrations (ex. Stripe), de nouvelles > variables seront ajoutées à `.env.example`. +## Déploiement production (karbe.cosmolan.fr) + +Le repo inclut une stack de self-hosting Docker pour le MVP: + +- `Dockerfile` (build Next.js standalone) +- `docker-compose.prod.yml` (app + reverse proxy Caddy HTTPS) +- `docker/Caddyfile` +- `.env.production.example` + +### 1) Préparer le serveur + +- Docker Engine + Docker Compose plugin installés. +- DNS `A`/`AAAA` de `karbe.cosmolan.fr` pointant vers le serveur. +- Ports `80` et `443` ouverts. + +### 2) Configurer l'environnement + +```bash +cp .env.production.example .env.production +``` + +Renseigner toutes les variables, en particulier `DATABASE_URL`, +`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_OWNER_SUBSCRIPTION_PRICE_ID`, +`APP_URL` et `NEXT_PUBLIC_SITE_URL`. + +### 3) Appliquer les migrations Prisma + +Les migrations doivent être appliquées avant le premier démarrage : + +```bash +npx prisma migrate deploy +``` + +### 4) Lancer la stack + +```bash +docker compose -f docker-compose.prod.yml up -d --build +``` + +Vérifier la santé: + +```bash +curl -fsS https://karbe.cosmolan.fr/api/health +``` + +### 5) Connecter le webhook Stripe (TEST) + +Depuis un poste ayant Stripe CLI: + +```bash +stripe listen --forward-to https://karbe.cosmolan.fr/api/stripe/webhook +``` + +Copier le secret affiché (`whsec_...`) dans `STRIPE_WEBHOOK_SECRET`, puis redéployer. + ## Développement ```bash diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..e0d7a4f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,42 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: karbe-app + restart: unless-stopped + env_file: + - .env.production + networks: + - karbe + healthcheck: + test: ["CMD", "wget", "-qO", "-", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + caddy: + image: caddy:2.10-alpine + container_name: karbe-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + depends_on: + app: + condition: service_healthy + volumes: + - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - karbe + +networks: + karbe: + name: karbe-net + +volumes: + caddy_data: + caddy_config: diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..be1c7b5 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,14 @@ +{ + email ops@cosmolan.fr +} + +karbe.cosmolan.fr { + encode zstd gzip + reverse_proxy app:3000 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + } +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..5f5ced5 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +export async function GET() { + return NextResponse.json({ status: "ok" }); +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 519e180..1e4296f 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -97,11 +97,16 @@ async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) { } async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { + const currentPeriodEnd = + subscription.items.data[0]?.current_period_end ?? + subscription.trial_end ?? + subscription.canceled_at; + await prisma.subscription.upsert({ where: { providerSubId: subscription.id }, update: { status: mapStripeSubscriptionStatus(subscription.status), - renewedAt: fromStripeTimestamp(subscription.current_period_end), + renewedAt: fromStripeTimestamp(currentPeriodEnd), canceledAt: fromStripeTimestamp(subscription.canceled_at), }, create: { @@ -111,7 +116,7 @@ async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { providerSubId: subscription.id, status: mapStripeSubscriptionStatus(subscription.status), startedAt: fromStripeTimestamp(subscription.start_date) ?? new Date(), - renewedAt: fromStripeTimestamp(subscription.current_period_end), + renewedAt: fromStripeTimestamp(currentPeriodEnd), canceledAt: fromStripeTimestamp(subscription.canceled_at), }, }); diff --git a/src/app/carbets/_components/search-filters.tsx b/src/app/carbets/_components/search-filters.tsx index 3b77f08..999f66c 100644 --- a/src/app/carbets/_components/search-filters.tsx +++ b/src/app/carbets/_components/search-filters.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; + import type { CarbetSearchFilters } from "@/lib/carbet-search"; type SearchFiltersProps = { @@ -72,12 +74,12 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
- Réinitialiser - +