feat: clear

This commit is contained in:
2026-07-08 20:27:54 +02:00
parent 4f16b5737f
commit ce1e57c5f0
16 changed files with 6341 additions and 561 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.next
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.DS_Store
.env*
!.env.example
coverage
build
out
.vercel
.idea
.tmp
-18
View File
@@ -1,19 +1 @@
APP_BASE_URL="https://leonmorival.xyz"
# Discord webhook du salon où arrivent les candidatures Minecraft.
DISCORD_WHITELIST_WEBHOOK_URL="" DISCORD_WHITELIST_WEBHOOK_URL=""
# Secret HMAC utilisé pour signer les liens Accepter/Refuser dans Discord.
# Minimum 32 caractères.
MINECRAFT_WHITELIST_REVIEW_SECRET=""
# Token Bearer optionnel pour un vrai bot Discord qui appelle
# POST /api/minecraft/whitelist/decision.
# Minimum 32 caractères.
MINECRAFT_WHITELIST_ADMIN_TOKEN=""
# RCON Minecraft. Garde ce port privé côté réseau si possible.
MINECRAFT_RCON_HOST="127.0.0.1"
MINECRAFT_RCON_PORT="25575"
MINECRAFT_RCON_PASSWORD=""
MINECRAFT_RCON_TIMEOUT_MS="5000"
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+65 -41
View File
@@ -1,66 +1,90 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Landing Server
Site Next.js pour les services de `leonmorival.xyz`.
## Minecraft whitelist ## Minecraft whitelist
La page Minecraft contient un formulaire de candidature. Une demande valide est envoyée vers Discord avec deux boutons-lien signés : La page Minecraft contient un formulaire de candidature. Une demande valide envoie uniquement une notification dans le webhook configure avec :
- `Accepter` appelle `/api/minecraft/whitelist/review` et exécute `whitelist add <pseudo>` via RCON. - le pseudo Minecraft ;
- `Refuser` marque la demande comme refusée et notifie Discord. - l'identifiant Discord renseigne ;
- le message du joueur.
Un vrai bot Discord peut aussi appeler `POST /api/minecraft/whitelist/decision` avec `Authorization: Bearer <MINECRAFT_WHITELIST_ADMIN_TOKEN>` et un JSON : Le site ne modifie pas directement la whitelist Minecraft.
```json Variable necessaire :
{
"username": "Leon",
"decision": "accept"
}
```
Variables nécessaires :
```bash ```bash
APP_BASE_URL="https://leonmorival.xyz"
DISCORD_WHITELIST_WEBHOOK_URL="https://discord.com/api/webhooks/..." DISCORD_WHITELIST_WEBHOOK_URL="https://discord.com/api/webhooks/..."
MINECRAFT_WHITELIST_REVIEW_SECRET="une-valeur-aleatoire-longue"
MINECRAFT_WHITELIST_ADMIN_TOKEN="une-autre-valeur-aleatoire-longue"
MINECRAFT_RCON_HOST="127.0.0.1"
MINECRAFT_RCON_PORT="25575"
MINECRAFT_RCON_PASSWORD="mot-de-passe-rcon"
``` ```
Active RCON côté serveur Minecraft, idéalement sans exposer publiquement le port `25575`. ## Developpement
## Getting Started
First, run the development server:
```bash ```bash
npm install
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Ouvre ensuite http://localhost:3000.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ## Production Docker
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. Le plus simple sans CI/CD : le serveur clone le repo, puis Docker build et lance l'app.
## Learn More ```bash
git pull --ff-only
docker compose up -d --build
```
To learn more about Next.js, take a look at the following resources: Le fichier `.env` du serveur doit contenir :
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ```bash
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. DISCORD_WHITELIST_WEBHOOK_URL="https://discord.com/api/webhooks/..."
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! Le conteneur expose l'app uniquement sur `127.0.0.1:3000`, pour la mettre derriere nginx.
## Deploy on Vercel ## Auto-update simple
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Le script [scripts/update-from-git-docker.sh](scripts/update-from-git-docker.sh) verifie si `origin/main` a un nouveau commit. Si oui, il fait :
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ```bash
git pull --ff-only
docker compose up -d --build --remove-orphans
```
Sur Ubuntu, tu peux l'appeler toutes les minutes avec un timer systemd :
```ini
# /etc/systemd/system/landing-server-update.service
[Unit]
Description=Update landing-server from Git
[Service]
Type=oneshot
Environment=APP_DIR=/var/www/landing-server
Environment=BRANCH=main
ExecStart=/var/www/landing-server/scripts/update-from-git-docker.sh
```
```ini
# /etc/systemd/system/landing-server-update.timer
[Unit]
Description=Check landing-server Git updates
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
Persistent=true
[Install]
WantedBy=timers.target
```
Puis :
```bash
sudo chmod +x /var/www/landing-server/scripts/update-from-git-docker.sh
sudo systemctl daemon-reload
sudo systemctl enable --now landing-server-update.timer
```
@@ -1,45 +0,0 @@
import {
applyWhitelistDecision,
parseWhitelistDecision,
} from "@/app/lib/minecraft-whitelist";
export const runtime = "nodejs";
function assertAuthorized(request: Request) {
const token = process.env.MINECRAFT_WHITELIST_ADMIN_TOKEN;
const authorization = request.headers.get("authorization");
if (!token || token.length < 32) {
throw new Error("Token admin whitelist non configuré.");
}
if (authorization !== `Bearer ${token}`) {
throw new Error("Non autorisé.");
}
}
export async function POST(request: Request) {
try {
assertAuthorized(request);
const payload = await request.json();
const { username, decision } = parseWhitelistDecision(payload);
const result = await applyWhitelistDecision(username, decision);
return Response.json({
ok: true,
result,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Décision impossible.";
return Response.json(
{
ok: false,
message,
},
{ status: message === "Non autorisé." ? 401 : 400 },
);
}
}
+5 -4
View File
@@ -14,14 +14,15 @@ export async function POST(request: Request) {
return Response.json({ return Response.json({
ok: true, ok: true,
message: "Demande envoyée sur Discord.", message: "Demande envoyée.",
}); });
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Impossible d'envoyer la demande."; error instanceof Error ? error.message : "Impossible d'envoyer la demande.";
const status = message.includes("configuré") || const status =
message.includes("APP_BASE_URL") || message.includes("configuré") ||
message.includes("Secret de revue") message.includes("DISCORD_") ||
message.startsWith("Le service de notification a refusé")
? 503 ? 503
: 400; : 400;
@@ -1,90 +0,0 @@
import {
applyWhitelistDecision,
verifyReviewToken,
} from "@/app/lib/minecraft-whitelist";
export const runtime = "nodejs";
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function htmlResponse(title: string, body: string, status = 200) {
return new Response(
`<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>${escapeHtml(title)}</title>
<style>
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background: #090a0f;
color: #f2efe3;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
main {
width: min(640px, calc(100% - 32px));
border: 4px solid #030303;
background: #252932;
box-shadow: 6px 6px 0 rgb(0 0 0 / 55%);
padding: 28px;
}
h1 {
margin: 0 0 12px;
color: #ffd54a;
font-size: 1.2rem;
}
p {
margin: 0;
line-height: 1.6;
}
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(body)}</p>
</main>
</body>
</html>`,
{
status,
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-Robots-Tag": "noindex, nofollow",
},
},
);
}
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const review = verifyReviewToken(url.searchParams.get("token"));
const result = await applyWhitelistDecision(review.username, review.decision);
return htmlResponse(
review.decision === "accept" ? "Demande acceptée" : "Demande refusée",
result,
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Action impossible.";
return htmlResponse("Action impossible", message, 400);
}
}
+3 -2
View File
@@ -13,10 +13,11 @@ export default function MinecraftWhitelistForm() {
async function submitRequest(event: FormEvent<HTMLFormElement>) { async function submitRequest(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const form = event.currentTarget;
setSubmitting(true); setSubmitting(true);
setStatus(null); setStatus(null);
const formData = new FormData(event.currentTarget); const formData = new FormData(form);
const payload = { const payload = {
username: String(formData.get("username") ?? ""), username: String(formData.get("username") ?? ""),
discord: String(formData.get("discord") ?? ""), discord: String(formData.get("discord") ?? ""),
@@ -38,7 +39,7 @@ export default function MinecraftWhitelistForm() {
throw new Error(result.message ?? "Demande refusée."); throw new Error(result.message ?? "Demande refusée.");
} }
event.currentTarget.reset(); form.reset();
setStatus({ setStatus({
tone: "success", tone: "success",
message: result.message ?? "Demande envoyée.", message: result.message ?? "Demande envoyée.",
+11 -194
View File
@@ -1,23 +1,12 @@
import crypto from "node:crypto";
import { sendRconCommand } from "./rcon";
export type WhitelistDecision = "accept" | "reject";
export type WhitelistRequest = { export type WhitelistRequest = {
username: string; username: string;
discord: string; discord: string;
reason: string; reason: string;
}; };
type ReviewTokenPayload = WhitelistRequest & {
decision: WhitelistDecision;
exp: number;
};
const usernamePattern = /^[A-Za-z0-9_]{3,16}$/; const usernamePattern = /^[A-Za-z0-9_]{3,16}$/;
const maxDiscordLength = 64; const maxDiscordLength = 64;
const maxReasonLength = 800; const maxReasonLength = 800;
const reviewTokenTtlMs = 7 * 24 * 60 * 60 * 1000;
export function parseWhitelistRequest(payload: unknown): WhitelistRequest { export function parseWhitelistRequest(payload: unknown): WhitelistRequest {
if (!payload || typeof payload !== "object") { if (!payload || typeof payload !== "object") {
@@ -44,215 +33,43 @@ export function parseWhitelistRequest(payload: unknown): WhitelistRequest {
return { username, discord, reason }; return { username, discord, reason };
} }
export function parseWhitelistDecision(payload: unknown): { async function readDiscordError(response: Response) {
username: string; const body = await response.text().catch(() => "");
decision: WhitelistDecision;
} {
if (!payload || typeof payload !== "object") {
throw new Error("Décision invalide.");
}
const input = payload as Partial<Record<"username" | "decision", unknown>>; return body
const username = String(input.username ?? "").trim(); ? `Le service de notification a refusé la demande (${response.status}) : ${body.slice(0, 200)}`
const decision = String(input.decision ?? "").trim(); : `Le service de notification a refusé la demande (${response.status}).`;
if (!usernamePattern.test(username)) {
throw new Error("Pseudo Minecraft invalide.");
}
if (decision !== "accept" && decision !== "reject") {
throw new Error("Décision invalide.");
}
return { username, decision };
}
function getReviewSecret() {
const secret = process.env.MINECRAFT_WHITELIST_REVIEW_SECRET;
if (!secret || secret.length < 32) {
throw new Error("Secret de revue whitelist manquant ou trop court.");
}
return secret;
}
function getBaseUrl() {
const baseUrl = process.env.APP_BASE_URL ?? process.env.NEXT_PUBLIC_SITE_URL;
if (!baseUrl) {
throw new Error("APP_BASE_URL est requis pour générer les boutons Discord.");
}
return baseUrl.replace(/\/$/, "");
}
function signTokenPayload(encodedPayload: string) {
return crypto
.createHmac("sha256", getReviewSecret())
.update(encodedPayload)
.digest("base64url");
}
function createReviewToken(request: WhitelistRequest, decision: WhitelistDecision) {
const payload: ReviewTokenPayload = {
...request,
decision,
exp: Date.now() + reviewTokenTtlMs,
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = signTokenPayload(encodedPayload);
return `${encodedPayload}.${signature}`;
}
export function verifyReviewToken(token: string | null) {
if (!token) {
throw new Error("Token manquant.");
}
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) {
throw new Error("Token invalide.");
}
const expectedSignature = signTokenPayload(encodedPayload);
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (
signatureBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
) {
throw new Error("Signature invalide.");
}
const payload = JSON.parse(
Buffer.from(encodedPayload, "base64url").toString("utf8"),
) as ReviewTokenPayload;
if (!usernamePattern.test(payload.username)) {
throw new Error("Pseudo Minecraft invalide.");
}
if (payload.decision !== "accept" && payload.decision !== "reject") {
throw new Error("Décision invalide.");
}
if (payload.exp < Date.now()) {
throw new Error("Token expiré.");
}
return payload;
}
function createReviewUrl(request: WhitelistRequest, decision: WhitelistDecision) {
const url = new URL("/api/minecraft/whitelist/review", getBaseUrl());
url.searchParams.set("token", createReviewToken(request, decision));
return url.toString();
} }
export async function sendWhitelistRequestToDiscord(request: WhitelistRequest) { export async function sendWhitelistRequestToDiscord(request: WhitelistRequest) {
const webhookUrl = process.env.DISCORD_WHITELIST_WEBHOOK_URL; const webhookUrl = process.env.DISCORD_WHITELIST_WEBHOOK_URL;
if (!webhookUrl) { if (!webhookUrl) {
throw new Error("Webhook Discord non configuré."); throw new Error("Service de notification non configuré.");
} }
const acceptUrl = createReviewUrl(request, "accept");
const rejectUrl = createReviewUrl(request, "reject");
const response = await fetch(webhookUrl, { const response = await fetch(webhookUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
content: "Nouvelle demande whitelist Minecraft", content: "Nouvelle demande d'accès Minecraft",
embeds: [ embeds: [
{ {
title: "Candidature Minecraft", title: "Candidature Minecraft",
color: 0x33e879, color: 0x33e879,
fields: [ fields: [
{ name: "Pseudo", value: request.username, inline: true }, { name: "Pseudo Minecraft", value: request.username, inline: true },
{ name: "Discord", value: request.discord, inline: true }, { name: "Discord", value: request.discord, inline: true },
{ name: "Motivation", value: request.reason.slice(0, maxReasonLength) }, { name: "Message", value: request.reason.slice(0, maxReasonLength) },
], ],
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
], ],
components: [ allowed_mentions: { parse: [] },
{
type: 1,
components: [
{
type: 2,
style: 5,
label: "Accepter",
url: acceptUrl,
emoji: { name: "✅" },
},
{
type: 2,
style: 5,
label: "Refuser",
url: rejectUrl,
emoji: { name: "❌" },
},
],
},
],
}), }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Discord a refusé la demande (${response.status}).`); throw new Error(await readDiscordError(response));
} }
} }
export async function sendWhitelistDecisionToDiscord(
username: string,
decision: WhitelistDecision,
result?: string,
) {
const webhookUrl = process.env.DISCORD_WHITELIST_WEBHOOK_URL;
if (!webhookUrl) {
return;
}
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content:
decision === "accept"
? `${username} a été ajouté à la whitelist Minecraft.`
: `❌ La demande de ${username} a été refusée.`,
embeds: result
? [
{
title: "Réponse RCON",
description: result || "Commande exécutée.",
color: decision === "accept" ? 0x33e879 : 0xff5c5c,
},
]
: undefined,
}),
});
}
export async function applyWhitelistDecision(
username: string,
decision: WhitelistDecision,
) {
if (decision === "reject") {
await sendWhitelistDecisionToDiscord(username, decision);
return "Demande refusée.";
}
const result = await sendRconCommand(`whitelist add ${username}`);
await sendWhitelistDecisionToDiscord(username, decision, result);
return result || `${username} ajouté à la whitelist.`;
}
-164
View File
@@ -1,164 +0,0 @@
import net from "node:net";
type RconPacket = {
id: number;
type: number;
body: string;
};
const RCON_AUTH = 3;
const RCON_COMMAND = 2;
function getRconConfig() {
const host = process.env.MINECRAFT_RCON_HOST;
const password = process.env.MINECRAFT_RCON_PASSWORD;
const port = Number(process.env.MINECRAFT_RCON_PORT ?? "25575");
const timeoutMs = Number(process.env.MINECRAFT_RCON_TIMEOUT_MS ?? "5000");
if (!host || !password) {
throw new Error("RCON n'est pas configuré.");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Le port RCON est invalide.");
}
return { host, password, port, timeoutMs };
}
function encodePacket(id: number, type: number, body: string) {
const bodyBuffer = Buffer.from(body, "utf8");
const packet = Buffer.alloc(4 + 4 + bodyBuffer.length + 2);
packet.writeInt32LE(id, 0);
packet.writeInt32LE(type, 4);
bodyBuffer.copy(packet, 8);
packet.writeInt8(0, 8 + bodyBuffer.length);
packet.writeInt8(0, 8 + bodyBuffer.length + 1);
const length = Buffer.alloc(4);
length.writeInt32LE(packet.length, 0);
return Buffer.concat([length, packet]);
}
function decodePackets(buffer: Buffer<ArrayBufferLike>) {
const packets: RconPacket[] = [];
let offset = 0;
while (offset + 4 <= buffer.length) {
const length = buffer.readInt32LE(offset);
const packetStart = offset + 4;
const packetEnd = packetStart + length;
if (packetEnd > buffer.length) {
break;
}
const id = buffer.readInt32LE(packetStart);
const type = buffer.readInt32LE(packetStart + 4);
const body = buffer.subarray(packetStart + 8, packetEnd - 2).toString("utf8");
packets.push({ id, type, body });
offset = packetEnd;
}
return {
packets,
rest: buffer.subarray(offset),
};
}
function waitForPacket(
socket: net.Socket,
timeoutMs: number,
isExpected: (packet: RconPacket) => boolean,
) {
return new Promise<RconPacket>((resolve, reject) => {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const cleanup = () => {
socket.off("data", onData);
socket.off("error", onError);
clearTimeout(timer);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onData = (chunk: Buffer<ArrayBufferLike>) => {
buffer = Buffer.concat([buffer, chunk]);
const decoded = decodePackets(buffer);
buffer = decoded.rest;
const packet = decoded.packets.find(isExpected);
if (packet) {
cleanup();
resolve(packet);
}
};
const timer = setTimeout(() => {
cleanup();
reject(new Error("Timeout RCON."));
}, timeoutMs);
socket.on("data", onData);
socket.on("error", onError);
});
}
function connectRcon(host: string, port: number, timeoutMs: number) {
return new Promise<net.Socket>((resolve, reject) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
reject(new Error("Connexion RCON expirée."));
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
resolve(socket);
});
socket.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
export async function sendRconCommand(command: string) {
const { host, password, port, timeoutMs } = getRconConfig();
const socket = await connectRcon(host, port, timeoutMs);
try {
const authId = 1;
const commandId = 2;
socket.write(encodePacket(authId, RCON_AUTH, password));
const authPacket = await waitForPacket(
socket,
timeoutMs,
(packet) => packet.id === authId || packet.id === -1,
);
if (authPacket.id === -1) {
throw new Error("Authentification RCON refusée.");
}
socket.write(encodePacket(commandId, RCON_COMMAND, command));
const responsePacket = await waitForPacket(
socket,
timeoutMs,
(packet) => packet.id === commandId,
);
return responsePacket.body.trim();
} finally {
socket.end();
socket.destroy();
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ export default function MinecraftPage() {
<section id="acces" className="minecraft-section whitelist-section"> <section id="acces" className="minecraft-section whitelist-section">
<div className="section-heading pixel-heading"> <div className="section-heading pixel-heading">
<h2>Demande d&apos;accès</h2> <h2>Demande d&apos;accès</h2>
<p>La candidature est envoyée sur Discord pour validation.</p> <p>La candidature est transmise pour validation.</p>
</div> </div>
<MinecraftWhitelistForm /> <MinecraftWhitelistForm />
+16
View File
@@ -0,0 +1,16 @@
services:
landing-server:
build:
context: .
container_name: landing-server
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:3000:3000"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone",
async headers() { async headers() {
return [ return [
{ {
+6158
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="${APP_DIR:-/var/www/landing-server}"
BRANCH="${BRANCH:-main}"
cd "$APP_DIR"
git fetch origin "$BRANCH"
LOCAL_COMMIT="$(git rev-parse HEAD)"
REMOTE_COMMIT="$(git rev-parse "origin/$BRANCH")"
if [ "$LOCAL_COMMIT" = "$REMOTE_COMMIT" ]; then
exit 0
fi
git pull --ff-only origin "$BRANCH"
docker compose up -d --build --remove-orphans
docker image prune -f