diff --git a/.dockerignore b/.dockerignore index 58fa821..7bd678a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,14 @@ build docker-compose.yml Dockerfile .dockerignore -public/videos/* \ No newline at end of file +public/videos/* + +# Local environment files may contain deployment secrets. +.env* + +# Never package applicant documents or local test credentials +private +.local-verification +.career-tests-* +media +scripts/.career-transfer diff --git a/.env.example b/.env.example index 01aef04..9ec2156 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,10 @@ POSTGRES_DB=frt FACEBOOK_PAGE_ID=your_page_id FACEBOOK_ACCESS_TOKEN=your_access_token + +# Karrier oldal — jelentkezések kiírása Google Sheets táblázatba (opcionális). +# A táblázat linkjét a szerkesztő adja meg a Payload adminban; ide csak a +# szolgáltatásfiók adatai kellenek. Beállítás nélkül a jelentkezések csak a +# Payload adminba kerülnek. +GOOGLE_SERVICE_ACCOUNT_EMAIL=frt-karrier@projekt-neve.iam.gserviceaccount.com +GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4259e83 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +src/migrations/*.json linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..88f7e7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + pull_request: + branches: + - main + - staging + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Lint and typecheck + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: yarn + + - name: Install dependencies + run: yarn --frozen-lockfile --network-timeout 600000 + + - name: Run ESLint + run: yarn eslint . + + - name: Run TypeScript checks + run: yarn next typegen && yarn tsc --noEmit + + - name: Test career applications + run: yarn test:career + + - name: Test career content transfer + run: yarn test:career-transfer + + container: + name: Build Docker image + needs: quality + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Build image without publishing + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: ./Dockerfile + platforms: ${{ vars.IMAGE_PLATFORM || 'linux/amd64' }} + push: false + cache-from: type=gha,scope=frt-app + cache-to: type=gha,mode=max,scope=frt-app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..feb3a28 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,222 @@ +name: Build and deploy + +on: + push: + branches: + - staging + workflow_dispatch: + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME: kir-dev/frt + +concurrency: + group: deploy-frt-vps + cancel-in-progress: false + +jobs: + quality: + name: Validate release + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Require main for production releases + if: github.event_name == 'workflow_dispatch' + run: | + if [[ "$GITHUB_REF_NAME" != "main" ]]; then + echo "Production releases must be dispatched from the main branch." >&2 + exit 1 + fi + + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: yarn + + - name: Install dependencies + run: yarn --frozen-lockfile --network-timeout 600000 + + - name: Run ESLint + run: yarn eslint . + + - name: Run TypeScript checks + run: yarn next typegen && yarn tsc --noEmit + + - name: Test career applications + run: yarn test:career + + - name: Test career content transfer + run: yarn test:career-transfer + + build: + name: Build and publish image + needs: quality + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + outputs: + image-ref: ${{ steps.image.outputs.ref }} + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image metadata + id: metadata + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,format=long,prefix=sha- + type=raw,value=staging,enable=${{ github.event_name == 'push' }} + type=raw,value=production,enable=${{ github.event_name == 'workflow_dispatch' }} + + - name: Build and push image + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: ./Dockerfile + platforms: ${{ vars.IMAGE_PLATFORM || 'linux/amd64' }} + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: type=gha,scope=frt-app + cache-to: type=gha,mode=max,scope=frt-app + provenance: true + sbom: true + + - name: Export immutable image reference + id: image + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: echo "ref=${REGISTRY}/${IMAGE_NAME}@${IMAGE_DIGEST}" >> "$GITHUB_OUTPUT" + + deploy: + name: Deploy ${{ github.event_name == 'push' && 'staging' || 'production' }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: read + environment: + name: ${{ github.event_name == 'push' && 'Staging' || 'Production' }} + url: ${{ vars.PUBLIC_URL }} + env: + IMAGE_REF: ${{ needs.build.outputs.image-ref }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} + COMPOSE_PROJECT: ${{ vars.COMPOSE_PROJECT }} + APP_ENV_FILE: ${{ vars.APP_ENV_FILE }} + HEALTH_URL: ${{ vars.HEALTH_URL }} + COMPOSE_OVERRIDE: ${{ github.event_name == 'push' && 'docker-compose.staging.yml' || '' }} + BACKUP_DATABASE: ${{ github.event_name == 'workflow_dispatch' && 'true' || 'false' }} + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Check out deployment files + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Validate deployment configuration + run: | + required_values=( + IMAGE_REF + DEPLOY_PATH + COMPOSE_PROJECT + APP_ENV_FILE + HEALTH_URL + VPS_HOST + VPS_USER + SSH_PRIVATE_KEY + SSH_KNOWN_HOSTS + ) + + for variable_name in "${required_values[@]}"; do + if [[ -z "${!variable_name}" ]]; then + echo "Missing deployment configuration: $variable_name" >&2 + exit 1 + fi + done + + if [[ ! "$DEPLOY_PATH" =~ ^/[a-zA-Z0-9_./-]+$ || "$DEPLOY_PATH" == *".."* ]]; then + echo "DEPLOY_PATH must be a safe absolute path." >&2 + exit 1 + fi + + - name: Configure SSH + run: | + install -m 700 -d "$HOME/.ssh" + printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deploy_key" + printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" + chmod 600 "$HOME/.ssh/deploy_key" "$HOME/.ssh/known_hosts" + + - name: Upload deployment files + run: | + target="${VPS_USER}@${VPS_HOST}" + ssh_options=(-i "$HOME/.ssh/deploy_key" -o BatchMode=yes -o StrictHostKeyChecking=yes) + printf -v prepare_command 'mkdir -p %q %q' "$DEPLOY_PATH" "$DEPLOY_PATH/scripts" + + # The command is intentionally assembled and escaped on the runner. + # shellcheck disable=SC2029 + ssh "${ssh_options[@]}" "$target" "$prepare_command" + scp "${ssh_options[@]}" docker-compose.yml docker-compose.staging.yml "$target:$DEPLOY_PATH/" + scp "${ssh_options[@]}" scripts/deploy-image.sh "$target:$DEPLOY_PATH/scripts/" + + - name: Authenticate VPS to GHCR + run: | + target="${VPS_USER}@${VPS_HOST}" + ssh_options=(-i "$HOME/.ssh/deploy_key" -o BatchMode=yes -o StrictHostKeyChecking=yes) + # GITHUB_ACTOR is intentionally expanded on the runner. + # shellcheck disable=SC2029 + printf '%s' "$GHCR_TOKEN" | ssh "${ssh_options[@]}" "$target" \ + "docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin" + + - name: Deploy immutable image + run: | + target="${VPS_USER}@${VPS_HOST}" + ssh_options=(-i "$HOME/.ssh/deploy_key" -o BatchMode=yes -o StrictHostKeyChecking=yes) + printf -v deploy_command \ + 'cd %q && ./scripts/deploy-image.sh %q %q %q %q %q %q' \ + "$DEPLOY_PATH" \ + "$IMAGE_REF" \ + "$COMPOSE_PROJECT" \ + "$APP_ENV_FILE" \ + "$HEALTH_URL" \ + "$BACKUP_DATABASE" \ + "$COMPOSE_OVERRIDE" + + # The command is intentionally assembled and escaped on the runner. + # shellcheck disable=SC2029 + ssh "${ssh_options[@]}" "$target" "$deploy_command" + + - name: Log the VPS out of GHCR + if: always() + continue-on-error: true + run: | + target="${VPS_USER}@${VPS_HOST}" + ssh -i "$HOME/.ssh/deploy_key" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + "$target" "docker logout ghcr.io" diff --git a/.gitignore b/.gitignore index 1955d09..c6aa4d3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,9 @@ yarn-error.log* next-env.d.ts /.idea/ /yarn.lock -public/videos \ No newline at end of file +public/videos +# Private applicant documents and local verification artifacts +/private/ +/media +/.local-verification/ +/scripts/.career-transfer/ diff --git a/Dockerfile b/Dockerfile index 8eed6b9..92e1974 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,34 +5,46 @@ RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json yarn.lock ./ -RUN yarn --frozen-lockfile +RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \ + NODE_OPTIONS=--dns-result-order=ipv4first \ + yarn --frozen-lockfile \ + --network-timeout 600000 \ + --network-concurrency 4 -FROM base AS builder +FROM base AS source WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 +RUN yarn run build:career-transfer + +FROM source AS builder RUN yarn run build -FROM base AS runner +FROM base AS career-transfer-runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY --from=source /app/src ./src +COPY --from=source /app/payload.config.ts ./payload.config.ts +COPY --from=source /app/tsconfig.json ./tsconfig.json +COPY --from=source /app/css-loader-register.mjs ./css-loader-register.mjs +COPY --from=source /app/css-loader-hooks.mjs ./css-loader-hooks.mjs +COPY --from=source /app/package.json ./package.json +COPY --from=source /app/scripts/career-transfer-server.mjs ./scripts/career-transfer-server.mjs +COPY --from=source /app/scripts/.career-transfer ./scripts/.career-transfer + +CMD ["node", "scripts/career-transfer-server.mjs"] + +FROM career-transfer-runner AS runner COPY --from=builder /app/public ./public - COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static -COPY --from=deps /app/node_modules ./node_modules -COPY --from=builder /app/src ./src -COPY --from=builder /app/payload.config.ts ./payload.config.ts -COPY --from=builder /app/tsconfig.json ./tsconfig.json -COPY --from=builder /app/css-loader-register.mjs ./css-loader-register.mjs -COPY --from=builder /app/css-loader-hooks.mjs ./css-loader-hooks.mjs -COPY --from=builder /app/package.json ./package.json EXPOSE 3000 diff --git a/README.md b/README.md index 7c5a62d..d3ad91a 100644 --- a/README.md +++ b/README.md @@ -90,37 +90,58 @@ Open http://localhost:3000. Admin is available at http://localhost:3000/admin. - `yarn start` — Start the production server - `yarn lint` — Run ESLint -## Deployment +## Karrier oldal + +A `/karrier` oldal (korábban „Tagfelvétel", a `/tagfelvetel` útvonal 308-cal ide irányít) mindig elérhető, akkor is, ha éppen nincs nyitott pozíció. + +### Tartalomkezelés + +- **Karrier csoportok** (`recruitment` kollekció) — csoportonként kép, rendezési szám, leírás, és a pozíciók listája. Egy pozícióhoz megadható: + - bevezető szöveg (opcionális), + - tetszőleges számú **szekció** (pl. Feladatok, Szükséges skillek, Miben fejlődhetsz, Előnyök), + - három **kiemelt tudnivaló**: Csatlakozás, Időráfordítás, Részegység — ezek ikonokkal jelennek meg a leírás alatt, + - „Nyitott pozíció" jelölőnégyzet: csak a bepipált pozíciók jelennek meg az oldalon. +- **Karrier oldal** globális beállítás (`career-settings`) — az oldal bevezetője, a jelentkezés módja (beépített űrlap vagy külső Google űrlap), és a jelentkezés nyitva/zárva állapota. +- **Jelentkezések** (`job-applications` kollekció) — a beépített űrlapon beérkezett jelentkezések. A kollekció `create` jogosultsága zárt: beküldeni csak a `/api/karrier/jelentkezes` végponton keresztül lehet. + +### Jelentkezések kiírása Google Sheets táblázatba + +A beépített űrlap minden jelentkezést elment a Payloadba, és ha be van állítva egy táblázat, egy sorral kiegészíti azt is. A táblázat hibája **nem** akadályozza meg a jelentkezés mentését — a státusz a jelentkezés oldalsávjában látszik (Kiírva / Kihagyva / Hiba), és a „Kiírás újra a táblázatba" gombbal bármikor pótolható. -Since the project uses `push: false` for database schema management, you must run migrations manually after deploying new code. +#### Egyszeri beállítás (fejlesztői oldal) -### Steps to Deploy +1. A [Google Cloud Console](https://console.cloud.google.com/)-ban hozz létre egy projektet, és engedélyezd benne a **Google Sheets API**-t. +2. **IAM & Admin → Service Accounts → Create service account.** Szerepkör nem kell hozzá. +3. A fióknál **Keys → Add key → Create new key → JSON**, töltsd le a kulcsot. +4. A letöltött JSON-ból két érték kell a `.env`-be: -1. **Pull latest changes**: - ```bash - git pull origin main - ``` + ``` + GOOGLE_SERVICE_ACCOUNT_EMAIL= + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY="" + ``` -2. **Rebuild Docker containers**: - ```bash - docker compose up -d --build - ``` + A privát kulcsot idézőjelek közé tedd, és hagyd benne a `\n` karaktereket — a kód alakítja vissza sortörésekké. -3. **Run Migrations**: - Execute the migration command inside the running container: - ```bash - docker exec frt-app-1 yarn payload:migrate - ``` - *Note: You may be prompted to confirm the migration. You can auto-confirm with `echo y | docker exec -i frt-app-1 yarn payload:migrate`.* +#### Táblázat bekötése (a szerkesztő dolga, kódolás nélkül) -### Troubleshooting Migrations +1. Hozz létre egy Google Sheets táblázatot (üresen is jó, a fejlécet mi írjuk bele). +2. **Megosztás → add hozzá a szolgáltatásfiók e-mail címét Szerkesztő jogosultsággal.** A pontos címet az adminban a mező alatt kiírjuk. +3. Payload admin → **Karrier oldal → Jelentkezés → Google táblázat linkje**: illeszd be a táblázat linkjét, és mentsd. -If you encounter `ERR_UNKNOWN_FILE_EXTENSION` errors with CSS files, ensure you are using the custom loader scripts (`css-loader-register.mjs`) which are included in the Docker image and used by the `yarn payload:migrate` script. +Mentéskor rögtön ellenőrizzük, hogy a táblázat elérhető-e; ha nincs megosztva vagy rossz a link, a mező hibaüzenetet ad. A mező üresen hagyható — ilyenkor a jelentkezések csak az adminba érkeznek. -- Ensure Docker is running if you use Postgres via Compose. -- On first run, Payload will initialize tables in the configured Postgres database. -- If you change database credentials, update `DATABASE_URI` accordingly. -- Static assets live under `public/`. Media uploaded via the CMS will be stored where your Payload storage is configured. +A kiírt oszlopok sorrendje: `Beküldve, Név, E-mail, Telefon, Egyetem / kar, Szak, Félév, Csoport, Pozíció, Motiváció`. + +## Deployment + +Docker images are built in GitHub Actions and published to GitHub Container Registry. The VPS only pulls an immutable image, runs the environment-specific Payload migrations, and recreates the application container without building source code. + +- Pushes to `staging` deploy automatically to the isolated staging Compose project. +- Production is started manually from the `main` branch and is protected by the GitHub `Production` environment. +- Production deployments create a PostgreSQL backup before migrations. +- Failed application health checks restore the previous application image. Database migrations are never rolled back automatically. + +See [docs/deployment.md](docs/deployment.md) for environment setup, required secrets and variables, first deployment, verification, and rollback instructions. The older [preview runbook](docs/preview.md) remains available as a manual fallback. ## What this site includes @@ -129,3 +150,36 @@ If you encounter `ERR_UNKNOWN_FILE_EXTENSION` errors with CSS files, ensure you - Theming and reusable UI components If you have questions or want to contribute, feel free to open an issue or PR. + +### Szerkeszthető űrlap és önéletrajzok + +A **Karrier oldal → Űrlap kérdései** fülön szerkeszthetők a magyar/angol feliratok, +segédszövegek, sorrend, kötelezőség és láthatóság. Új kérdésként rövid vagy hosszú +szöveg, egy- vagy többválasztós mező és jelölőnégyzet adható hozzá. A név, e-mail +és hozzájárulás kötelező; a csoport és pozíció mező megőrzi az automatikus kitöltést. +A hozzájárulás mindig az űrlap végén jelenik meg. A kérdés- és opcióazonosítók +állandók. A jelentkezések a beküldéskori kérdéseket, opciókat és válaszokat is tárolják. +Ha kitöltés közben módosul az űrlap, a beküldés frissíti a kérdéseket és újraellenőrzést +kér, miközben a beírt válaszok és a kiválasztott CV megmaradnak. + +Az opcionális CV egy érvényes, nem titkosított PDF lehet, maximum **5 MiB**. +A fájl a privát `application-cvs` kollekcióba kerül, csak bejelentkezett admin +olvashatja vagy töltheti le. A jelentkezés törlése a hozzá tartozó fájlt is törli. +A nyilvános médiatárba nem kerül önéletrajz. Helyi tárolási útvonal: +`private/cvs`; a `CV_STORAGE_DIR` változóval felülírható. Dockerben külön, +környezetenként elkülönített `applicant_cvs` kötet tárolja a fájlokat. + +Állítsd be a **`NEXT_PUBLIC_SERVER_URL`** változót az adott környezet nyilvános +HTTPS címére (helyben például `http://localhost:3107`). A Google-táblázat a +korábbi tíz oszlop után a **További válaszok** és **Jelentkezés az adminban** +oszlopokat kapja. A második linkről adminbelépés után tölthető le a CV. +Ha a K–L oszlopban már saját adatok vannak, az export hibát jelez, és nem írja +felül őket: előbb helyezd át ezeket, majd használd az újraszinkronizálás gombot. +A jelentkezés és CV táblázathiba esetén is megmarad. + +A **Kapcsolat** bejegyzésben szerkeszthető a kiállítási gomb HTTPS linkje és +magyar/angol felirata. Üres linknél a gomb nem jelenik meg. Ezeket a beállításokat +csak bejelentkezett admin módosíthatja. + +Ellenőrzések: `yarn test:career`, `yarn tsc --noEmit`, `yarn eslint <érintett fájlok>`. +Adatbázis-frissítés: `yarn payload:migrate`; generált típusok: `yarn payload:generate-types`. diff --git a/docker-compose.preview.yml b/docker-compose.preview.yml new file mode 100644 index 0000000..c0985bd --- /dev/null +++ b/docker-compose.preview.yml @@ -0,0 +1,10 @@ +services: + postgres: + env_file: !override + - .env.preview + + app: + ports: !override + - "127.0.0.1:3001:3000" + env_file: !override + - .env.preview diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..36454fa --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,10 @@ +services: + postgres: + env_file: !override + - ${APP_ENV_FILE:-.env.staging} + + app: + ports: !override + - "127.0.0.1:3001:3000" + env_file: !override + - ${APP_ENV_FILE:-.env.staging} diff --git a/docker-compose.yml b/docker-compose.yml index 5dd8a65..1f00a6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +5,11 @@ services: volumes: - postgres:/var/lib/postgresql/data env_file: - - .env + - ${APP_ENV_FILE:-.env} networks: - postgres healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB" ] + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] interval: 1s timeout: 10s start_period: 10s @@ -17,25 +17,38 @@ services: app: restart: always + image: ${FRT_APP_IMAGE:-frt-app:local} ports: - - "3000:3000" + - "127.0.0.1:3000:3000" build: context: . dockerfile: Dockerfile volumes: - frt:/app/media + - applicant_cvs:/app/private/cvs - ./public/videos:/app/public/videos depends_on: postgres: condition: service_healthy env_file: - - .env + - ${APP_ENV_FILE:-.env} networks: - postgres + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3000/api/health').then((response) => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1)) + interval: 10s + timeout: 5s + start_period: 30s + retries: 6 volumes: postgres: frt: + applicant_cvs: networks: postgres: diff --git a/docs/career-content-transfer.md b/docs/career-content-transfer.md new file mode 100644 index 0000000..c7a4e2b --- /dev/null +++ b/docs/career-content-transfer.md @@ -0,0 +1,245 @@ +# Karriertartalom átvitele + +A `scripts/career-transfer.mjs` kizárólag elkülönített helyi próbához készült. +A `127.0.0.1:55441` címen futó PostgreSQL-konténert használja; a staging +másolatából olvas, és csak a `frt_transfer_candidate` adatbázisba importál. +Éles adatbázist ez a helyi futtató nem fogad el. A Docker-image külön +`scripts/career-transfer-server.mjs` futtatót tartalmaz a szerveres végrehajtáshoz. + +## Adatmodell és hatókör + +- `recruitment`: meglévő csoportok frissítése explicit staging → production + összerendeléssel, a production csoportazonosítók megtartásával. +- A csoporton belüli pozíciók és szekciók a staging listáját veszik át; + a korábbi production pozíciólista nem adódik hozzá automatikusan. +- `career-settings`: minden tartalmi mező és jelentkezési beállítás a stagingből. +- A hivatkozott képfájlok és médiarekordok. Egyező fájlnév, SHA-256 és alt esetén + meglévő rekordot használ; egyébként tartalomlenyomatot tartalmazó néven tölt fel. +- A jelentkezések, CV-k, felhasználók, más oldaltartalmak és meglévő médiarekordok + nem módosulnak. Nincs teljes adatbáziscsere vagy SQL-adatdump összeolvasztás. + +Az `applicationMode`, `applicationsOpen`, `spreadsheetUrl` és `googleFormUrl` +értékeit is a staging határozza meg, a felhasználó döntése alapján. +A stagingben megadott Google-táblázat lesz az átvett cél; az üres mezők is +felülírják a korábbi célértékeket. Az integráció hitelesítési adatai továbbra +is a célkörnyezet konfigurációjából származnak, az import ezeket nem másolja. + +## A próba környezete + +Az adatokat és naplókat az ignorált, 700-as jogosultságú `.local-verification/` +könyvtárban tároljuk. Ne commitold, és ne tedd publikus helyre a dumpokat, +exportokat, archívumokat vagy hitelesítési adatokat. + +Előfeltétel: a projekt függőségei, Docker, Python 3 és a két ellenőrzött +PostgreSQL 17 dump, valamint a production/staging fájlarchívum. + +A jelenlegi helyi konténer neve `frt-career-transfer-db`, image-e +`postgres:17-alpine`, kötete `frt-career-transfer-pg`, portja `127.0.0.1:55441`. +A `.local-verification/postgres.env` helyben generált hitelesítési adatokat +tartalmaz: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`. + +A három adatbázis szerepe: + +| Adatbázis | Szerep | +| --- | --- | +| `frt_transfer_prod` | Production dump változatlan helyreállítása | +| `frt_transfer_staging` | Staging dump helyreállítása, export forrása | +| `frt_transfer_candidate` | Production másolata, migráció és próbaimport célja | + +A restore `pg_restore --no-owner --no-acl --exit-on-error --single-transaction` +opciókkal történt, új, üres adatbázisokba. A candidate a helyreállított +production adatbázisból `createdb --template` segítségével készült. + +## Futtatás + +Mindig a worktree gyökeréből futtasd. A parancsok nem töltik be a projekt +`.env` fájljait: a wrapper csak a helyi PostgreSQL-jelszót és minimális +környezeti változókat ad át. E-mail küldése és külső `fetch` tiltott. + +```bash +node scripts/career-transfer.mjs frt_transfer_staging export +node scripts/career-transfer.mjs frt_transfer_candidate migrate +node scripts/career-transfer.mjs frt_transfer_candidate export +``` + +Ha a dump `dev` migrációjelölőt tartalmaz, a Payload megerősítést kér. +A meglévő próbában a hat hiányzó migráció sikeresen lefutott, a forrásdumpok +változatlanok maradtak. Éles migrációt csak friss mentés és az aktuális +adatbázisséma ellenőrzése után végezz. + +A `prepare-career-bundle` csak a hivatkozott képeket és az egyező nevű +production képeket bontja ki. Videókat és CV-ket nem másol. A fájlok +méretét a dumpban tárolt mérettel is összeveti. Meglévő fájlt nem ír felül; +új forráscsomaghoz új próbaterületet használj. + +```bash +node scripts/prepare-career-bundle.mjs /abs/staging-files.tar.gz /abs/prod-files.tar.gz +``` + +A `.local-verification/group-mapping.json` fájl minden forráscsoporthoz egy +rekordot tartalmaz: `sourceID`, `sourceName`, `targetID`, `targetName`. +Hiányzó, ismétlődő vagy név alapján már nem egyező összerendelés hibát okoz. +A mostani konkrét megfeleltetés és változáslista a helyi `rehearsal-report.md` +fájlban és az `import-plan-first.json` tervben szerepel. + +```bash +node scripts/career-transfer.mjs frt_transfer_candidate audit audit-before.json +node scripts/career-transfer.mjs frt_transfer_candidate plan +# Az import-plan.json ellenőrzése után, kizárólag a helyi másolaton: +node scripts/career-transfer.mjs frt_transfer_candidate apply +node scripts/career-transfer.mjs frt_transfer_candidate audit audit-after.json +``` + +A terv SHA-256-tal rögzíti a forráscsomagot, az összerendelést és a célállapotot. +Az apply egy PostgreSQL-tranzakcióban zárolja az érintett táblákat, újraellenőrzi +a tervet, végrehajtja az importot, és összehasonlítja az eredményt a forrással. +Validációs hibánál visszagörget és eltávolítja az újonnan írt képfájlokat. +Bizonytalan COMMIT-válasznál a fájlokat megőrzi, a napló `commit-uncertain` +állapotot kap: ilyen esetben adatbázis-ellenőrzés szükséges újrafuttatás előtt. + +Ismételt futtatáshoz készíts új tervet. Változatlan forrás és cél esetén ez +nulla módosítást tartalmaz. A régi, import előtti tervet az apply elutasítja. + +```bash +yarn test:career-transfer +node scripts/career-transfer.mjs frt_transfer_candidate verify-guards +``` + +A guard-próba helyi tesztképet hoz létre, majd szándékosan hibás utolsó +csoporttal teszteli a korábbi módosítások visszagörgetését és a fájltakarítást. +A PostgreSQL sequence számlálója visszagörgetés után is előreléphet; +az adatrekordok és fájlok ellenőrzötten változatlanok maradnak. + +## Éles kiadás előtt + +1. Ellenőrizni kell, változott-e a staging tartalma vagy a production adatbázis + a használt snapshotok óta; eltérés esetén friss export és új terv kell. +2. A pozíciólista és a karrierbeállítások staging szerinti átvétele jóváhagyott. +3. Ellenőrizni kell, hogy a production szolgáltatásfiók hozzáfér-e az átvett + Google-táblázathoz. A tartalmi célértékekről nem szükséges újabb döntés. +4. Productionben az új CV-funkcióhoz csatolni kell az `applicant_cvs` kötetet. +5. Rövid írásszünet alatt friss, összehangolt adatbázis- és fájlmentés kell. +6. A jóváhagyott kódot migrációkkal ki kell adni, majd az alábbi szerveres + importtal átvinni ugyanazt az ellenőrzött tartalomcsomagot. +7. Magyar/angol oldal, képek, űrlap és az integráció beállításainak ellenőrzése. + +A kód kiadása és a tartalom importálása külön művelet. Az importáló nem +indít deployt; adatot kizárólag az explicit `apply` parancs módosít. + +## Docker-csomagolás és szerveres futtató + +A Docker build a `build:career-transfer` scripttel előre lefordítja az importot. +A `career-transfer-runner` stage tartalmazza a Linuxos függőségeket, a pontos +Payload-konfigurációt, migrációkat és az importot; a teljes alkalmazás `runner` +stage-e erre épül. A szerveren az import nem telepít függőséget és nem fordít. +Az image nem tartalmaz feltöltött médiát, CV-ket, mentéseket vagy exportcsomagot. + +```bash +docker build --target career-transfer-runner -t frt-career-transfer:local . +docker build -t frt-app:career-transfer-local . +``` + +A szerveres futtató két parancsot fogad: `plan` és `apply`. Nem futtat automatikus +migrációt. Hiányzó migráció esetén leáll, mielőtt importtervet vagy adatot írna. +A konfiguráció környezeti változóiból csak az adatbáziskapcsolatot, a Payload +titkot és az alkalmazás címét adja át. Google- és e-mail-hitelesítési adatok +nem jutnak az importfolyamatba; külső `fetch` és e-mail-küldés tiltott. + +Minden szerveres terv tartalmazza a környezetet, az alkalmazás originjét, az +adatbázis nevét, hostját, szerepkörét és az elért PostgreSQL-szerver címét. +Az apply újra ellenőrzi ezt az azonosítást, a forráscsomagot, a képfájlokat és +a céladatokat. A helyi próbában készült tervet ezért nem lehet productionben +alkalmazni: ott új, csak olvasást végző `plan` futtatás szükséges. + +### Átadandó fájlok + +A megerősített exportcsomagot a szerverre másold egy korlátozott jogosultságú +könyvtárba, például `/home/frt/imports/career-20260907` alá: + +```text +career-20260907/ + bundle/ + bundle.json + media/ # csak a 9 hivatkozott kép + group-mapping.json +``` + +Ne másold ide a teljes média- és videóarchívumokat. A csomag kb. 77 MB képet +tartalmaz; a mentések továbbra is külön visszaállítási tartalékok. +Az importtervek és futási naplók ugyanebbe a könyvtárba kerülhetnek. + +### Terv készítése a production környezetben + +Előfeltétel: az új kód Docker-image-e már ki van adva, a migrációk lefutottak, +a production média- és CV-kötetek csatolva vannak. A használt image-et a +`/home/frt/frt/.env.deploy` rögzíti. A lent megadott könyvtárak a szerveren +ellenőrzött elrendezést használják, nem a régebbi `/srv` példákat. + +Először csak az alkalmazás originjét és az adatbázis nevét ellenőrizd, +hitelesítési adatok kiírása nélkül: + +```bash +docker exec frt-app-1 node -e ' + const db = new URL(process.env.DATABASE_URI); + console.log(JSON.stringify({ + origin: new URL(process.env.NEXT_PUBLIC_SERVER_URL).origin, + database: decodeURIComponent(db.pathname.slice(1)) + })); +' +``` + +Az alábbi parancsokban az `EXPECTED_ORIGIN` értékét erre az ellenőrzött címre +állítsd. A `frt` adatbázisnévnek szintén egyeznie kell a fenti eredménnyel. +Az írások legyenek szüneteltetve a friss mentéstől az import ellenőrzéséig. + +```bash +cd /home/frt/frt +EXPECTED_ORIGIN='https://AZ-ELLENORZOTT-PRODUCTION-DOMAIN' +TRANSFER_DIR=/home/frt/imports/career-20260907 + +docker compose --env-file .env.deploy -p frt -f docker-compose.yml \ + run --rm -T --no-deps --pull never \ + -v "$TRANSFER_DIR:/transfer" --entrypoint node app \ + scripts/career-transfer-server.mjs plan \ + --environment production --expect-database frt \ + --expect-origin "$EXPECTED_ORIGIN" \ + --bundle /transfer/bundle/bundle.json \ + --mapping /transfer/group-mapping.json \ + --out /transfer/production-plan.json +``` + +Ellenőrizd a kiírt célt, változásszámokat és a tervet. A staging tartalom a +mérvadó, de váratlan célállapot esetén ne kerüld meg a terv ellenőrzését. + +### Az ellenőrzött terv alkalmazása + +Az `APPROVED_PLAN_SHA256` változóba az előző parancs által kiírt, ellenőrzött +terv pontos `sha256` értékét másold. Ez a terv JSON-tartalmának lenyomata, +nem a formázott JSON-fájl `sha256sum` eredménye. + +```bash +APPROVED_PLAN_SHA256='AZ-ELLENORZOTT-TERV-64-KARAKTERES-LENYOMATA' + +docker compose --env-file .env.deploy -p frt -f docker-compose.yml \ + run --rm -T --no-deps --pull never \ + -v "$TRANSFER_DIR:/transfer" --entrypoint node app \ + scripts/career-transfer-server.mjs apply \ + --environment production --expect-database frt \ + --expect-origin "$EXPECTED_ORIGIN" \ + --bundle /transfer/bundle/bundle.json \ + --mapping /transfer/group-mapping.json \ + --plan /transfer/production-plan.json \ + --plan-sha256 "$APPROVED_PLAN_SHA256" \ + --receipt /transfer/production-receipt.json +``` + +A sikeres napló státusza `committed`. `prepared`, `pending-commit` vagy +`commit-uncertain` státusz esetén előbb ellenőrizni kell az adatbázis aktuális +állapotát és a folyamatot; ne feltételezz automatikus visszagörgetést. +`failed-before-commit` esetén a futtató a commit előtti hibát jelezte. +A futtató nem ír felül meglévő tervet vagy naplót. Új próbához új fájlnév kell. + +Siker után új `plan` futtatás külön fájlba nulla csoportmódosítást, nulla új +médiát és változatlan globális beállításokat kell mutasson. Ellenőrizd a +magyar és angol oldalt, az űrlapot és a production szolgáltatásfiók táblázat- +hozzáférését, majd oldd fel az írásszünetet. Az import nem küld tesztjelentkezést. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..3a0bd14 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,215 @@ +# GHCR-alapú staging és production deployment + +Az alkalmazás Docker image-e GitHub Actionsben épül, majd a GitHub Container Registrybe kerül. A VPS nem buildel forráskódot: az immutable image digestet lehúzza, lefuttatja az adott környezet migrációit, és `--no-build` módban újraindítja az alkalmazást. + +## Környezetek + +| Beállítás | Staging | Production | +| --- | --- | --- | +| Git ág / indítás | push a `staging` ágra | kézi `workflow_dispatch` a `main` ágról | +| GitHub Environment | `Staging` | `Production` | +| Compose projekt | `frt-preview` | `frt` | +| VPS könyvtár | `/srv/frt-preview` | `/srv/frt` | +| App env fájl | `.env.preview` | `.env` | +| Host health URL | `http://127.0.0.1:3001/api/health` | `http://127.0.0.1:3000/api/health` | +| PostgreSQL és media volume | staging-specifikus | production-specifikus | +| Adatbázis-backup migráció előtt | nem | igen | + +A Compose projektnevek külön volume-okat és hálózatokat hoznak létre. A workflow soha ne használja a staging projektnevet production env fájllal vagy fordítva. + +## Workflow-k + +### Pull request CI + +A `.github/workflows/ci.yml` a `main` vagy `staging` célágú pull requesteken futtatja: + +1. a függőségek reprodukálható telepítését; +2. az ESLint ellenőrzést; +3. a TypeScript ellenőrzést; +4. a teljes production Docker image buildet, publikálás nélkül. + +### Image build és deployment + +A `.github/workflows/release.yml` két módon indul: + +- `staging` push: automatikus staging build és deployment; +- kézi indítás: kizárólag a `main` ágról engedélyezett production build és deployment. + +A workflow Buildxszel a repository `IMAGE_PLATFORM` változójában megadott platformra épít image-et, BuildKit cache-t használ, majd SHA taget, környezeti aliast és immutable digestet publikál a `ghcr.io/kir-dev/frt` package-be. A VPS mindig a digestet kapja, nem a változó `staging` vagy `production` taget. Az alapértelmezett és jelenleg beállított platform `linux/amd64`. + +A build job írási, a deploy job csak olvasási package-jogot kap. A staging és production release-ek ugyanazon VPS Docker hitelesítését használhatják, ezért a workflow sorosítja őket. + +Ha a VPS ARM64 architektúrájú, az `IMAGE_PLATFORM` repository változót `linux/arm64`-re vagy többplatformos értékre kell módosítani az első release előtt. Ellenőrzés: + +```bash +uname -m +``` + +## GitHub Environments beállítása + +A repository **Settings → Environments** oldalán legyen `Staging` és `Production` environment. + +Mindkettőben szükségesek ezek a secretek: + +| Secret | Tartalom | +| --- | --- | +| `VPS_HOST` | A VPS DNS-neve vagy IP-címe | +| `VPS_USER` | Nem root deploy felhasználó, Docker jogosultsággal | +| `SSH_PRIVATE_KEY` | Külön CI deploy kulcs privát része | +| `SSH_KNOWN_HOSTS` | A VPS ellenőrzött SSH host kulcsa | + +Az SSH host kulcs fingerprintjét külön, megbízható csatornán ellenőrizd. A workflow `StrictHostKeyChecking=yes` beállítást használ, ezért ismeretlen host kulcsot nem fogad el. + +### Staging változók + +| Variable | Javasolt érték | +| --- | --- | +| `DEPLOY_PATH` | `/srv/frt-preview` | +| `COMPOSE_PROJECT` | `frt-preview` | +| `APP_ENV_FILE` | `.env.preview` | +| `HEALTH_URL` | `http://127.0.0.1:3001/api/health` | +| `PUBLIC_URL` | a staging publikus URL-je | + +### Production változók + +| Variable | Javasolt érték | +| --- | --- | +| `DEPLOY_PATH` | `/srv/frt` | +| `COMPOSE_PROJECT` | `frt` | +| `APP_ENV_FILE` | `.env` | +| `HEALTH_URL` | `http://127.0.0.1:3000/api/health` | +| `PUBLIC_URL` | a production publikus URL-je | + +A `Production` environmenthez állíts be required reviewert és csak a `main` ágat engedélyezd deployment branchként. A kézi workflow-indítás mellett ez egy második jóváhagyási kapu. + +## VPS egyszeri előkészítése + +Előfeltételek: + +- Docker Engine és Docker Compose 2.24.4 vagy újabb; +- `curl`; +- egy nem root deploy felhasználó, amely hozzáfér a Docker sockethez; +- a deploy felhasználó SSH publikus kulcsa az `authorized_keys` fájlban; +- környezetenként külön könyvtár, env fájl, adatbázis és media volume. + +Könyvtárak: + +```bash +sudo install -d -o -g /srv/frt /srv/frt-preview +sudo install -d -o -g /srv/frt/public/videos /srv/frt-preview/public/videos +``` + +A production `/srv/frt/.env` és staging `/srv/frt-preview/.env.preview` fájlokat kézzel kell létrehozni, `chmod 600` jogosultsággal. A `DATABASE_URI` hostja mindkét fájlban `postgres` legyen, és a két környezet külön `PAYLOAD_SECRET` értéket használjon. + +A workflow minden release-nél feltölti a Compose fájlokat és a `scripts/deploy-image.sh` scriptet, de az env fájlokat, médiát és videókat nem írja felül. + +## Deployment sorrend + +A remote deploy script: + +1. ellenőrzi a kapott image-, projekt-, env- és health paramétereket; +2. eltárolja a jelenlegi app image referenciáját rollbackhez; +3. lehúzza az új GHCR image digestet; +4. elindítja és megvárja a környezet PostgreSQL konténerét; +5. productionben timestampelt custom-format `pg_dump` mentést készít a `backups/` könyvtárba; +6. egyszer használatos új app konténerből lefuttatja a Payload migrációkat; +7. `docker compose up --no-build` paranccsal elindítja az új appot; +8. ellenőrzi a Docker healthchecket és a host `HEALTH_URL` végpontot; +9. siker esetén `.env.deploy` fájlban rögzíti az aktív immutable image referenciát. + +A backup fájlok automatikus törlése szándékosan nincs bekapcsolva. Állíts be külön, ellenőrzött retention szabályt, miután a rendelkezésre álló VPS tárhely és a szükséges megőrzési idő ismert. + +## Health check + +Az `/api/health` a Next.js folyamat mellett egy minimális Payload lekérdezéssel az adatbázis-kapcsolatot is ellenőrzi: + +- `200 {"status":"ok"}`: az alkalmazás és az adatbázis elérhető; +- `503 {"status":"unhealthy"}`: a Payload inicializáció vagy az adatbázis-lekérdezés hibázott. + +A válasz nem tartalmaz belső hibarészleteket, és `Cache-Control: no-store` fejlécet kap. + +## Első staging release + +1. Készítsd elő `/srv/frt-preview/.env.preview` fájlt és a staging adatokat a `docs/preview.md` alapján. +2. Állítsd be a `Staging` GitHub environment secretjeit és változóit. +3. Merge-eld a pipeline változásait a `staging` ágba. +4. Kövesd a **Build and deploy** workflow-t. +5. Ellenőrizd: + +```bash +cd /srv/frt-preview +docker compose --env-file .env.deploy -p frt-preview \ + -f docker-compose.yml -f docker-compose.staging.yml ps +curl --fail http://127.0.0.1:3001/api/health +``` + +## Production release + +1. A staging release és az alkalmazásszintű smoke tesztek legyenek sikeresek. +2. Merge-eld a kiadandó kódot a `main` ágba. +3. GitHub Actions → **Build and deploy** → **Run workflow**. +4. Branchként kötelezően a `main` ágat válaszd. +5. Hagyd jóvá a `Production` environment deploymentet. +6. Siker után ellenőrizd az alkalmazást és a `backups/` könyvtárban létrejött adatbázismentést. + +## Rollback + +Ha az új app nem indul el vagy a health check hibázik, a script visszaindítja az előző application image-et. Az új migrációkat nem vonja vissza. + +Kézi application rollbackhez írd az előző digestet a `.env.deploy` `FRT_APP_IMAGE` értékébe, majd: + +```bash +docker compose --env-file .env.deploy -p \ + -f docker-compose.yml [-f docker-compose.staging.yml] \ + up -d --no-build --pull never --wait app +``` + +Adatbázis-visszaállítást csak külön jóváhagyással és a mentés ellenőrzése után végezz. Egy hibás application release önmagában nem indokol automatikus production adatbázis-restore-t. + +## Manuális fallback + +Ha a GitHub Actions átmenetileg nem elérhető, egy már publikált digest a VPS-en kézzel is telepíthető: + +```bash +cd /srv/frt-preview +./scripts/deploy-image.sh \ + 'ghcr.io/kir-dev/frt@sha256:' \ + frt-preview \ + .env.preview \ + http://127.0.0.1:3001/api/health \ + false \ + docker-compose.staging.yml +``` + +Ez sem végez helyi Docker buildet. + +## Privát önéletrajzok mentése és helyreállítása + +A Compose `applicant_cvs` kötete az `/app/private/cvs` könyvtárra csatlakozik. +A staging és production külön Compose projektnevét továbbra is kötelező használni. +A helyi CV-ket és tesztadatokat a Docker build és a Git figyelmen kívül hagyja. +A `NEXT_PUBLIC_SERVER_URL` az adott környezet publikus címe legyen, hogy a +Google-táblázatban szereplő adminhivatkozások teljes URL-ek legyenek. + +A production telepítési script az adatbázismentés mellé `postgres--cvs.tar.gz` +fájlt is készít. Sikertelen fájlmentés esetén a migráció nem indul el. Mindkét +mentés személyes adatokat tartalmazhat; a script 600-as fájljogosultságot állít be. +Az adatbázis és a CV-archívum együtt kezelendő. Konzisztens kézi mentéshez és +helyreállításhoz állítsd le az alkalmazás írásait/alkalmazáskonténerét. + +Helyreállítás próbája kizárólag elkülönített környezetben: + +1. Az adatbázis dumpját állítsd vissza egy külön PostgreSQL-adatbázisba. +2. Az azonos időpontú archívumot bontsd ki az elkülönített privát kötetbe + (`tar -xzf -C `; az archívumban `cvs/` szerepel). +3. Indítsd el az alkalmazást ezzel az adatbázissal és `CV_STORAGE_DIR` útvonallal. +4. Adminbelépéssel tölts le egy önéletrajzot, és hasonlítsd össze az eredeti + SHA-256 lenyomatával. Belépés nélkül ugyanez a URL legyen elutasítva. +5. Újraindítás után ismételd meg a letöltést. Az eredeti környezetet a próba nem módosíthatja. + +A korábbi migrációs snapshotok már tartalmazták a Kapcsolat-táblát, de az +üres adatbázist felépítő migrációs lánc nem. A `contact_baseline` migráció ezt +`IF NOT EXISTS` műveletekkel pótolja, meglévő kapcsolati adatok megőrzésével. +Visszavonása szándékosan nem törli a táblát. A következő migráció az új kérdéseket, +CV-kapcsolatot és kiállítási gombmezőket adja hozzá; éles adatokat tartalmazó +adatbázison visszavonás helyett előre javító migrációt használj. diff --git a/docs/preview.md b/docs/preview.md new file mode 100644 index 0000000..688e441 --- /dev/null +++ b/docs/preview.md @@ -0,0 +1,269 @@ +# VPS preview környezet + +Ez a runbook a `feat/karrier-oldal` ág megrendelői előnézetét indítja el úgy, hogy a preview saját Compose projektet, PostgreSQL volume-ot, media volume-ot, hálózatot és host portot használ. + +## Izoláció + +| Erőforrás | Production | Preview | +| --- | --- | --- | +| Compose projekt | `frt` | `frt-preview` | +| PostgreSQL volume | `frt_postgres` | `frt-preview_postgres` | +| Media volume | `frt_frt` | `frt-preview_frt` | +| App port | `127.0.0.1:3000` / jelenlegi prod beállítás | `127.0.0.1:3001` | +| Környezeti fájl | `.env` | `.env.preview` | + +Az override a Compose `!override` tagjával lecseréli a base fájl `ports` és `env_file` listáit. Ehhez Docker Compose **2.24.4 vagy újabb** szükséges. + +> A preview parancsokból soha ne hagyd el a `-p frt-preview` és a két `-f` kapcsolót. A migrációkat kizárólag a `frt-preview` projekt app konténerében futtasd. + +## 1. Előkészítés + +A preview-nak külön checkoutot vagy worktree-t használj; ne válts feature ágra a production checkoutban. + +```bash +cd /srv/frt-preview +git fetch origin +git switch feat/karrier-oldal +git pull --ff-only origin feat/karrier-oldal +docker compose version +``` + +Másold át a production `.env` fájlt `.env.preview` néven, majd adj neki új `PAYLOAD_SECRET` értéket: + +```bash +cp /srv/frt/.env .env.preview +chmod 600 .env.preview +openssl rand -hex 32 +``` + +A generált értéket kézzel írd be a `.env.preview` `PAYLOAD_SECRET` mezőjébe. Ellenőrizd még ezeket: + +- a `DATABASE_URI` hostja `postgres` legyen (például `postgres://user:password@postgres:5432/frt`), és ne production IP vagy külső adatbázis-host; +- a `POSTGRES_USER`, `POSTGRES_PASSWORD` és `POSTGRES_DB` egyezzen a `DATABASE_URI` adataival; +- a fájl ne kerüljön Gitbe vagy Docker build contextbe (`.env*` mindkét helyen ignorálva van); +- a Google service account adatai maradhatnak, de az adatbázisban tárolt táblázatlinket később teszt-táblázatra kell átállítani. + +Ellenőrizd a konfigurációt anélkül, hogy kiíratnád a secreteket: + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + config --quiet +``` + +## 2. Preview adatbázis létrehozása és a production tartalom másolása + +Először csak a preview PostgreSQL-t indítsd el. Így az app nem kezd el dolgozni az üres adatbázison a mentés visszatöltése előtt. + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + up -d postgres +``` + +Készíts logikai mentést a production adatbázisról. A következő parancs a production konténer saját env változóit használja, ezért nem kell jelszót vagy adatbázisnevet a shellbe másolni: + +```bash +docker compose -p frt -f /srv/frt/docker-compose.yml exec -T postgres \ + sh -c 'pg_dump --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"' \ + > /tmp/frt-prod.sql +``` + +Töltsd vissza a mentést a még üres preview adatbázisba. Az `ON_ERROR_STOP` miatt az első SQL-hibánál leáll a folyamat. + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + exec -T postgres \ + sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" "$POSTGRES_DB"' \ + < /tmp/frt-prod.sql +``` + +Sikeres visszatöltés után törölhető az ideiglenes SQL-fájl: + +```bash +rm /tmp/frt-prod.sql +``` + +## 3. Build és preview migrációk + +Építsd meg a feature ág app image-ét, majd egy egyszer használatos preview app konténerből futtasd a migrációkat. Ez még nem publikál portot. + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + build app + +echo y | docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + run --rm -T app yarn payload:migrate +``` + +Ez a `frt-preview_postgres` volume adatbázisát módosítja. A production adatbázison nem fut migráció. + +Ezután indítsd el a preview appot: + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + up -d app +``` + +## 4. Feltöltött média másolása + +A media a preview saját `frt-preview_frt` volume-jába kerül. A másolást csak az app indulása után végezd el, hogy a célkonténer és a volume már létezzen. + +```bash +FRT_PREVIEW_MEDIA_TMP="$(mktemp -d /tmp/frt-preview-media.XXXXXX)" +docker cp frt-app-1:/app/media/. "$FRT_PREVIEW_MEDIA_TMP/" +docker cp "$FRT_PREVIEW_MEDIA_TMP/." frt-preview-app-1:/app/media +rm -rf "$FRT_PREVIEW_MEDIA_TMP" +``` + +Ha a production Compose projekt- vagy szolgáltatásneve eltér, a tényleges konténernevet a `docker compose -p frt ps` paranccsal keresd meg. + +## 5. Ellenőrzés a VPS-en + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + ps + +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + logs --tail=100 app postgres + +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + exec -T app yarn payload:migrate-status + +curl --fail --head http://127.0.0.1:3001/karrier +docker volume ls --filter name=frt-preview +docker network ls --filter name=frt-preview +``` + +Elvárt preview volume-ok: `frt-preview_postgres` és `frt-preview_frt`. A `3001` port csak a VPS localhost interfészén hallgat; kívülről kizárólag nginxen keresztül érhető el. + +## 6. nginx, basic auth és TLS + +Hozd létre a basic auth jelszófájlt (Debian/Ubuntu esetén a `htpasswd` az `apache2-utils` csomagban van): + +```bash +sudo apt-get install apache2-utils +sudo htpasswd -c /etc/nginx/.htpasswd-preview frt-preview +sudo chown root:www-data /etc/nginx/.htpasswd-preview +sudo chmod 640 /etc/nginx/.htpasswd-preview +``` + +Hozd létre az `/etc/nginx/sites-available/frt-preview` fájlt: + +```nginx +server { + listen 80; + listen [::]:80; + server_name preview.frtbme.hu; + client_max_body_size 20M; + + auth_basic "FRT preview"; + auth_basic_user_file /etc/nginx/.htpasswd-preview; + add_header X-Robots-Tag "noindex, nofollow" always; + + location / { + proxy_pass http://127.0.0.1:3001; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + proxy_pass_request_headers on; + proxy_redirect http://127.0.0.1:3001 /; + } +} +``` + +Engedélyezd és töltsd újra az nginxet: + +```bash +sudo ln -s /etc/nginx/sites-available/frt-preview /etc/nginx/sites-enabled/frt-preview +sudo nginx -t +sudo systemctl reload nginx +``` + +Miután a `preview.frtbme.hu` DNS A rekordja a VPS-re mutat: + +```bash +sudo certbot --nginx -d preview.frtbme.hu +sudo nginx -t +sudo systemctl reload nginx +``` + +Külső ellenőrzés: + +```bash +curl -I https://preview.frtbme.hu +``` + +A válasznak bejelentkezés nélkül `401 Unauthorized` státuszt és `X-Robots-Tag: noindex, nofollow` fejlécet kell adnia. + +## 7. Kötelező alkalmazásszintű ellenőrzések + +Mielőtt a linket elküldöd a megrendelőnek: + +1. Lépj be a preview Payload adminba a productionból átmásolt felhasználóval. +2. A **Karrier oldal → Jelentkezés → Google táblázat linkje** mezőt állítsd külön teszt-táblázatra, vagy hagyd üresen. +3. Ha teszt-táblázatot használsz, oszd meg a `.env.preview` service account e-mail-címével. +4. Küldj be egy próba jelentkezést, és ellenőrizd a Payload rekordot, valamint a teszt-táblázatot. +5. Ellenőrizd, hogy a basic auth minden publikus és admin útvonal előtt megjelenik. + +Az adatbázis-másolat a production admin felhasználóit és a career settings tartalmát is tartalmazza. Az eltérő `PAYLOAD_SECRET` miatt a production session cookie-k nem használhatók a preview-n, de a megszokott felhasználónév/jelszó igen. + +## Leállítás és eltávolítás + +Ideiglenes leállítás, az adatok megtartásával: + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + stop +``` + +Konténerek és hálózat eltávolítása, a volume-ok megtartásával: + +```bash +docker compose \ + --env-file .env.preview \ + -p frt-preview \ + -f docker-compose.yml \ + -f docker-compose.preview.yml \ + down +``` + +Csak akkor használd a `down -v` parancsot, ha a preview adatbázisát és feltöltött médiáját is végleg törölni akarod. A `-p frt-preview` ilyenkor különösen fontos. diff --git a/docs/security-updates.md b/docs/security-updates.md new file mode 100644 index 0000000..02d0063 --- /dev/null +++ b/docs/security-updates.md @@ -0,0 +1,56 @@ +# Biztonsági frissítés – 2026. szeptember 6. + +Ág: `feat/security-updates`, a karrierfejlesztésre építve. Az ellenőrzések helyi környezetben történtek; éles rendszert és Google-táblázatot nem módosítottunk. + +## Függőségek + +- Next.js 15.5.9 → 16.3.4; React és React DOM 19.2.1 → 19.2.8. +- Payload és minden közvetlen `@payloadcms/*` csomag egységesen 3.88.0. A csomagok verzióját rögzítettük. Az új `@payloadcms/next` peer dependency tartománya nem támogatja a Next.js 15.5 ágat, ezért szükséges a 16-os főverzió. +- A közvetlenül importált `sharp` most explicit, 0.35.4-es függőség. A nem használt közvetlen `image-size` függőséget eltávolítottuk; az új csomaggráfban a sérülékeny `image-size`, `fast-xml-parser` és `tar` csomag sem szerepel. +- A lockfile többi csomagját a megadott verziótartományokon belül frissítettük, köztük a Drizzle ORM-et 0.45.2-re. +- A Drizzle Kit régi `@esbuild-kit/core-utils` függősége esbuild 0.18-at kér. Egy szűk, erre az útvonalra vonatkozó Yarn resolution 0.25.12-re emeli. Ez szándékos kompatibilitási figyelmeztetést okoz telepítéskor; a szinkron/aszinkron TypeScript-fordítást és a Payload migrációgenerálást külön ellenőriztük. Más esbuild-verziót nem ír felül. + +## Megmaradó auditjelzés és helyi védelem + +Ugyanazzal a `yarn audit --json` ellenőrzéssel, advisory-azonosító szerint összevonva: + +| Súlyosság | Frissítés előtt | Frissítés után | +| --- | ---: | ---: | +| Kritikus | 2 | 0 | +| Magas | 71 | 0 | +| Közepes | 46 | 1 | +| Alacsony | 11 | 0 | + +Az egyetlen megmaradó jelzés a `GHSA-jg8r-5jh2-v2xj`: a Payload alapértelmezett fiókfeloldási jogosultsága. A 3.88.0-s kiadáshoz sincs publikált javítás. A `Users.access.unlock` ezért minden HTTP-kliens számára tiltja a műveletet. A böngészős integrációs ellenőrzés bejelentkezett és névtelen kérésnél is 403 választ igazolt, miközben a normál adminbejelentkezés továbbra is működött. A csomagszintű audit ettől még közepes hibát jelez, és 4-es kilépési kóddal tér vissza; nem rejtettük el a jelzést. A külön feloldási végpont jelenleg nem használható adminból sem. + +A GitHub default branchhez tartozó Dependabot-számláló külön mérés: az csak a módosítások integrálása után változhat. + +## Kompatibilitás + +- A build továbbra is Webpacket használ (`next build --webpack`), így a Next.js 16 alapértelmezett buildeszköz-váltása nem módosítja a csomagolást. +- A megszűnt `next lint` helyett `eslint .` fut, a Next.js natív flat konfigurációjával. Két új React Compiler-szabály figyelmeztetés marad: a Compiler nincs bekapcsolva, a meglévő komponensek átírása külön feladat. Az összes eddigi lintellenőrzés megmaradt; jelenleg 0 hiba és 9 figyelmeztetés van. +- A Next.js frissítette a TypeScript JSX-beállítását és a generált route-típusok hivatkozásait. A CI és a release típusellenőrzése előtt `next typegen` fut. +- A YouTube-képek engedélyezése a régi `images.domains` helyett HTTPS `remotePatterns` beállítást használ. + +## Korábbi eseményséma-hiány javítása + +A teljes oldalas böngészőteszt kimutatta, hogy a migrációkból felépített helyi adatbázisból hiányzott az események `slug` és `link_to_picture_from_event_id` oszlopa, és a `location` még kötelező volt. Ezek a mezők/beállítások már a frissítés előtti forráskódban és snapshotokban szerepeltek; nem a Payload-frissítés változtatta meg a sémát. + +A `20260906_220000_events_baseline` migráció pótolja az oszlopokat, indexeket és a galériahivatkozást, valamint opcionálissá teszi a helyszínt. A meglévő URL-eket megtartja, a hiányzókat egyedi `esemeny-{id}` értékkel tölti ki, ütközés esetén számozással. Visszavonáskor nem töröl felhasználói adatot. A telepítés során a szokásos migrációs lépés szükséges. + +## Ellenőrzés + +- Production build, típusellenőrzés, lint és 12/12 automatizált jelentkezési teszt sikeres. A CI típusgenerálása és típusellenőrzése külön, `.env` és korábbi `.next` állományok nélküli könyvtárban is sikeres. +- Látható Chromiumban 13 publikus oldal, adminbejelentkezés és kérdésmentés; pozíció-előtöltés; megváltozott űrlap 409-es kezelése a válaszok és a CV megtartásával; valódi helyi magyar beküldés PDF-fel és angol beküldés PDF nélkül; adminban visszaolvasott válaszok; admin PDF-letöltés és névtelen hozzáférés tiltása. Nem volt JavaScript-oldalhiba. Az ideiglenes kérdést és jelentkezéseket a teszt végén eltávolítottuk. +- Világos/sötét téma, 1440/390 px, HU/EN: 38 kontraszt- és állapotellenőrzés sikeres, vízszintes túlcsordulás nélkül. A vizsgált szövegek legkisebb kontrasztja 5,34:1. +- Mobilmenü, témaváltás, helyszín nélküli tesztesemény létrehozása, naptárbejegyzés és eseményrészletező oldal sikeres; a teszteseményt eltávolítottuk. +- A sikerpipa animációja 440 ms alatt lefut; csökkentett mozgásnál statikusan jelenik meg. +- Az eseménymigrációt a frissítés előtti adatbázis külön másolatán is kétszer lefuttattuk: a meglévő URL és eseményadat megmaradt, az ütköző generált URL egyedi utótagot kapott. +- Az audit- és böngészős bizonyítékok helyileg a Gitből kizárt `.local-verification/` könyvtárban találhatók. A tesztek helyi tesztadatokat használtak; a külső szolgáltatások teljes működése és az éles telepítés nem része ennek az ellenőrzésnek. + +## Források + +- [Next.js augusztusi biztonsági kiadás](https://nextjs.org/blog/august-2026-security-release) +- [Next.js 16 frissítési útmutató](https://nextjs.org/docs/app/guides/upgrading/version-16) +- [Payload fiókfeloldási jogosultság](https://github.com/advisories/GHSA-jg8r-5jh2-v2xj) +- [esbuild fejlesztői szerver](https://github.com/advisories/GHSA-67mh-4wv8-2f99) diff --git a/eslint.config.mjs b/eslint.config.mjs index c85fb67..0a7b7a1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,16 +1,30 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTypescript from "eslint-config-next/typescript"; const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + ".next/**", + "coverage/**", + ".local-verification/**", + ".career-tests-*/**", + "scripts/.career-transfer/**", + "private/**", + "next-env.d.ts", + "node_modules/**", + "src/payload-types.ts", + ], + }, + ...nextVitals, + ...nextTypescript, + { + // New React Compiler diagnostics stay visible without making this security + // upgrade depend on unrelated component rewrites. Compiler is not enabled. + rules: { + "react-hooks/set-state-in-effect": "warn", + "react-hooks/immutability": "warn", + }, + }, ]; export default eslintConfig; diff --git a/karrier-ellenorzes.md b/karrier-ellenorzes.md new file mode 100644 index 0000000..6f2a9fe --- /dev/null +++ b/karrier-ellenorzes.md @@ -0,0 +1,32 @@ +# Karrierfejlesztés – helyi ellenőrzés + +2026. szeptember 6. Helyi alkalmazás: http://127.0.0.1:3107/karrier + +- `yarn build`: sikeres (187 másodperc). Két meglévő, az érintett funkcióktól független lintfigyelmeztetés maradt a `car-section.tsx` és `image-gallery.tsx` fájlokban. +- `yarn tsc --noEmit`: sikeres. Célzott ESLint, `git diff --check`, a telepítési script szintaxisellenőrzése és a staging Compose konfiguráció ellenőrzése sikeres. +- `yarn test:career`: 12/12 teszt sikeres. Kötelező alapmezők, választós mezők, hosszkorlát, hibás PDF és feltöltési méret, JSON/multipart bemenet, fejlécütközés, szinkronhiba és teljes 12 oszlopos RAW export. +- Valódi helyi HTTP-próbák: régi JSON beküldés; dinamikus kötelező kérdés és hamis opció; konfigurációváltozás (409); PDF mentés/letöltés; névtelen fájlhozzáférés (403); hiányzó adminjog és hibás kiállítási URL; zárt/külső jelentkezés; honeypot és kéréskorlát (429). +- Kényszerített adatbázishiba után a feltöltött CV takarítása sikeres. Jelentkezés törlésekor a CV rekordja és fájlja is eltűnik. A konfigurációból törölt kérdés válaszai és eredeti feliratai a korábbi jelentkezésben megmaradnak. +- Látható Chromium-böngésző: 1440 px asztali, 390 px mobil és 420 px magas képernyő; magyar/angol cím és menü; kilenc csoport; GYIK a jelentkezés előtt; pozíció-előtöltés; HU beküldés CV-vel és EN beküldés CV nélkül. A panel és az első csoport felső éle azonos, a GYIK-ugrás a felső menü alá érkezik. Nem volt JavaScript-oldalhiba. +- Adminból új, kötelező, kétnyelvű kérdés létrehozása és mentése sikeres. Egy korábban megnyitott űrlap beküldése jelezte a változást, megőrizte a válaszokat és a CV-t; az új kérdés kitöltése után sikeres volt a beküldés. A jelentkezés az adminban CV-vel és olvasható válaszösszesítővel jelenik meg. +- A buildből indított standalone alkalmazáson az üres GYIK, a zárt/külső jelentkezési mód, a billentyűzetes szakasznavigáció és az újraindítás utáni privát CV-letöltés is sikeresen ellenőrizve. +- A kiállítási gomb új lapon megnyitotta a megadott Google-űrlapot, címe: „BME FRT - Rendezvényen való kiállítási igény”. +- Adatbázis- és CV-mentés visszaállítása külön adatbázisba és könyvtárba sikeres. A CV-hivatkozás és a fájl SHA-256 lenyomata egyezik. A visszaállított adatbázison az új migráció visszavonása és ismételt futtatása sikeres; a régi jelentkezés és a kapcsolati tartalom megmaradt. + +A helyi adatbázis `frt_career`, külön Docker-konténerben, az 55439-es porton fut. +A kilenc csoport, a kapcsolati szöveg és a jelentkezések helyi tesztadatok. +A helyi admin adatai a Gitből és a Docker buildből kizárt +`.local-verification/admin.json` fájlban vannak. Az eredeti `.env` változatlan; +a helyi környezet külön `.env.local` beállításokat használ, üres Google-szolgáltatásfiókkal. + +A Google Sheets-exportot szimulált API-válaszokkal ellenőriztük, valódi táblázatba +nem írtunk. Élesítéskor a dokumentált publikus URL-beállítás és a tartós privát +CV-kötet szükséges. Az ellenőrzés helyi környezetben történt; push és élesítés nem történt. + +## Világos téma kontrasztjavítása + +- A karrieroldal külön szemantikus színkészletet használ. Világos módban semleges kártyák, sötét szövegek, jól látható mezőkeretek és piros, fehér feliratú gombok jelennek meg. A korábbi globális színfelülírások nem érintik ezeket az elemeket. +- Látható Chromium: világos magyar 1440/390 px, világos angol 1440 px, sötét magyar 1440 px és sötét angol 390 px. Összesen 38 állapotellenőrzés: alapnézet, nyitott csoport/pozíció, oldalsáv, GYIK, hover, mezőfókusz, hibás és sikeres beküldés. Nem volt vízszintes túlcsordulás vagy JavaScript-oldalhiba. +- A helyi teszttartalom megjelenített szövegeinek legkisebb mért kontrasztja 5,34:1. A normál mezőkeretek kontrasztja a környező kártyán világos módban 4,02:1, sötét módban 4,56:1. A sikerpipa kontrasztja világos módban 6,40:1. Ez a vizsgált tartalomra és állapotokra vonatkozó mérés, nem teljes webhely-audit. +- A beküldési állapotokat szimulált HTTP-válaszokkal ellenőriztük; ez az ellenőrzés nem hozott létre jelentkezéseket. Részletes mérések és képek: `.local-verification/career-contrast-results.json`, `career-light-form.png`, `career-light-success.png` és a témánkénti képernyőképek. +- `yarn tsc --noEmit`, a karrieroldal célzott ESLint-ellenőrzése és `git diff --check`: sikeres. A helyi fejlesztői szerver továbbra is a 3107-es porton fut. diff --git a/karrier-visszajelzesek-terv.md b/karrier-visszajelzesek-terv.md new file mode 100644 index 0000000..5fa813d --- /dev/null +++ b/karrier-visszajelzesek-terv.md @@ -0,0 +1,18 @@ +# Karrieroldal és kiállítási igény – javítási terv + +Megvalósítva 2026. szeptember 6-án. Az alkalmazás és az admin ellenőrzése elkülönített helyi PostgreSQL-adatbázissal és tesztadatokkal történt. A build, a típusellenőrzés és a 12 célzott teszt sikeres; éles környezetet nem módosítottunk. + +Javasolt alapbeállítás: adminból szerkeszthető kérdések; opcionális, egyetlen PDF önéletrajz, legfeljebb 5 MiB; angol főcím: „Join our team!”. Ezek a jóváhagyott terv szerinti alapbeállítások. + +- [x] **1. Oldalszerkezet és főcím.** A `career-page-client.tsx` címét és bevezetőjét a teljes tartalmi konténert átfogó sorba emelni. Alatta közös kéthasábos elrendezés: balra csoportok → GYIK → jelentkezés, jobbra az első csoport felső éléhez igazított panel. A főcím „Csatlakozz csapatunkhoz!”, a magyar menüfelirat „Karrier”. A fordításokat a `translations.ts` kezeli. Mobilon egyhasábos tartalom marad. Elfogadás: hosszabb bevezetővel is az első csoport mellett kezdődik a panel. +- [x] **2. Oldalsáv és szakasznavigáció.** A `career-sidebar.tsx` három eleme: alapból csukott „Csoportok” lenyíló, „Gyakran ismételt kérdések” ugrólink, jelentkezési gomb. A lista a CMS aktuális csoportjait használja, nincs beégetett kilences darabszám. Csoportválasztáskor a célcsoport kinyílik és odagörgetünk; a listát visszacsukjuk. A GYIK stabil `gyik` horgonyt kap, linkje csak az adott nyelven megjelenő GYIK esetén látszik. A `use-career-page.ts` görgetése és a sticky távolság a felső menü alá igazodik; alacsony képernyőn minden panelvezérlő elérhető. Billentyűzet, fókuszjelzés, `aria-expanded`, csökkentett mozgás támogatása. A zárt jelentkezés és a külső Google-űrlap mód viselkedése megmarad. +- [x] **3. Menüsorrend.** A `src/config/navigation.ts` listában a Karrier a Rólunk után kerül. Elfogadás: asztali és mobilmenüben, magyarul és angolul is ez az utolsó tartalmi menüpont; a láthatósági beállítás tovább működik. +- [x] **4. Szerkeszthető jelentkezési kérdések.** A `CareerSettings.ts` új „Űrlap kérdései” fülén a meglévő mezők felirata, súgója és sorrendje szerkeszthető; az opcionális mezők elrejthetők vagy kötelezővé tehetők. Név, e-mail és hozzájárulás kötelező alapmező marad, a csoport/pozíció automatikus kitöltése megmarad. Új kérdésként rövid/hosszú szöveg, egy- vagy többválasztós kérdés és jelölőnégyzet adható hozzá, HU/EN felirattal és opciókkal. Stabil kérdés- és opcióazonosítók; az `application-form.tsx` és a szervervalidáció ugyanazt a konfigurációt követi. A `JobApplications.ts` az új válaszok mellé a beküldéskori kérdésszöveget, opciókat, nyelvet és konfigurációverziót is menti. Kérdés törlése a régi válaszokat nem törli; beküldés közbeni konfigurációváltozásnál érthető hibajelzés, adatvesztés nélkül. Elfogadás: adminban hozzáadott kérdés megjelenik, validálódik és visszaolvasható. +- [x] **5. CV-feltöltés.** A kérdések után, a hozzájárulás és beküldés előtt fájlmező: fájlnév, eltávolítás/csere, típus- és mérethatár jelzése. A `/api/karrier/jelentkezes` multipart beküldést fogad, szerveroldalon is ellenőrzi a méretet és PDF-tartalmat; a korábbi JSON beküldés támogatása megmarad. Új privát upload-kollekció és kapcsolat a jelentkezéshez: beküldés kizárólag az ellenőrzött API-n át, olvasás/letöltés csak belépett adminnak. Külön tartós Docker-kötet, például `/app/private/cvs`, környezetenként elkülönítve, mentéssel és helyreállítási próbával. Hibás jelentkezés után ne maradjon árva fájl; jelentkezéstörlés a CV-t is takarítsa. Meglévő botvédelem megtartása, teljes kérésméret korlátozása. Elfogadás: CV-vel és nélküle is sikeres beküldés, hibás/túlméretes fájl elutasítása, névtelen letöltés tiltása, újraindítás után elérhető dokumentum. A jogosultsági megoldást a [Payload feltöltési dokumentációja](https://payloadcms.com/docs/upload/overview) támogatja. +- [x] **6. Google Sheets és adatmigráció.** A `karrier-sheet-sync.ts` / `google-sheets.ts` a meglévő tíz oszlop után „További válaszok” és „Jelentkezés az adminban” oszlopot kap; utóbbiból érhető el a CV is belépés után. Az összesítő a beküldéskori kérdés–válasz párokat tartalmazza. A jelenlegi fejléc csak üres táblánál jön létre, ezért ellenőrzött fejlécbővítés kell, idegen oszlop felülírása nélkül. Sheets-hiba nem akadályozhatja a jelentkezés/CV mentését; adminból újraszinkronizálható marad. Payload-migrációk, típusgenerálás és README-frissítés; a régi rekordok új mezői opcionálisak, a jelenlegi űrlap az induló konfiguráció. +- [x] **7. Kiállítási igény gomb.** A `Contact.ts` kollekcióban szerkeszthető URL és HU/EN gombfelirat, a `kapcsolat/page.tsx` innen olvas. A megadott [Google-űrlap](https://docs.google.com/forms/d/e/1FAIpQLSdrA3VX3YpxQB3uZ1ojXov8-J3LPawAt4ip_Aj4k8FNbNOCrA/viewform?usp=dialog) legyen az induló cél, a meglévő rekordot is kitöltő migrációval; üres URL-nél nincs gomb. HTTPS URL-validáció, új lapos megnyitás. A jelenlegi `Contact` nyilvános létrehozási/frissítési hozzáférését bejelentkezett adminra kell szűkíteni, hogy a publikus API-n se lehessen átírni a linket. Elfogadás: adminból módosított link jelenik meg, névtelen írás tiltott. A böngészős próba megnyitotta a „BME FRT - Rendezvényen való kiállítási igény” űrlapot. +- [x] **8. Végső ellenőrzés.** Elkülönített tesztadatbázison migráció és visszaolvasás régi jelentkezésekkel; TypeScript (`yarn tsc --noEmit`), célzott ESLint (`yarn eslint …`), build. Célzott API-tesztek a dinamikus validációra, fájlhozzáférésre és hibák utáni takarításra; Sheets-próba kizárólag teszttáblán. Böngészős próba HU/EN nyelven, mobilon és asztali/ alacsony képernyőn: sorrend, paneligazítás, csoport/ GYIK ugrás, pozíció-előtöltés, üres GYIK, zárt/külső jelentkezés, teljes beküldés és adminos CV-letöltés. Élesítés külön egyeztetett lépés. + +Megvalósítási sorrend: 1–3. megjelenés → 4–6. szerkeszthető űrlap és CV teljes adatútja → 7. kapcsolati gomb → 8. ellenőrzés. A megjelenési rész önállóan is szállítható; az űrlap, tárolás, export és migráció együtt alkot teljes változtatást. + +Ellenőrzési részletek: [karrier-ellenorzes.md](./karrier-ellenorzes.md). A Google Sheets-kéréseket izolált, szimulált teszttáblával ellenőriztük; valódi Google-táblázatba nem írtunk. diff --git a/next-env.d.ts b/next-env.d.ts index 36a4fe4..ca6651b 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,8 @@ /// /// /// -/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index 0f60396..7209986 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,8 +4,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", images: { - domains: ["i.ytimg.com"], remotePatterns: [ + { + protocol: "https", + hostname: "i.ytimg.com", + }, { protocol: "https", hostname: "**.fbcdn.net", @@ -16,6 +19,16 @@ const nextConfig: NextConfig = { }, ], }, + async redirects() { + return [ + { + // A Tagfelvétel oldal Karrier néven, /karrier útvonalon él tovább. + source: "/tagfelvetel", + destination: "/karrier", + permanent: true, + }, + ]; + }, /* config options here */ }; diff --git a/package.json b/package.json index 2ced8c0..7581c85 100644 --- a/package.json +++ b/package.json @@ -5,35 +5,40 @@ "private": true, "scripts": { "dev": "next dev --turbopack", - "build": "next build", + "build": "next build --webpack", + "build:career-transfer": "node scripts/build-career-transfer.mjs", + "career:transfer": "node scripts/career-transfer-server.mjs", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "payload:migrate": "node --import ./css-loader-register.mjs ./node_modules/.bin/payload migrate", "payload:migrate-status": "node --import ./css-loader-register.mjs ./node_modules/.bin/payload migrate:status", - "payload:generate-types": "node --import ./css-loader-register.mjs ./node_modules/.bin/payload generate:types" + "payload:generate-types": "node --import ./css-loader-register.mjs ./node_modules/.bin/payload generate:types", + "test:career": "node scripts/test-career.mjs", + "test:career-transfer": "node --test tests/career-transfer.test.mjs tests/career-transfer-server.test.mjs" }, "dependencies": { "@fullcalendar/core": "^6.1.19", "@fullcalendar/daygrid": "^6.1.19", "@fullcalendar/interaction": "^6.1.19", "@fullcalendar/react": "^6.1.19", - "@payloadcms/db-postgres": "^3.80.0", - "@payloadcms/drizzle": "^3.80.0", - "@payloadcms/email-nodemailer": "^3.80.0", - "@payloadcms/next": "^3.80.0", - "@payloadcms/payload-cloud": "^3.80.0", - "@payloadcms/richtext-lexical": "^3.80.0", - "@payloadcms/translations": "^3.80.0", + "@payloadcms/db-postgres": "3.88.0", + "@payloadcms/drizzle": "3.88.0", + "@payloadcms/email-nodemailer": "3.88.0", + "@payloadcms/next": "3.88.0", + "@payloadcms/payload-cloud": "3.88.0", + "@payloadcms/richtext-lexical": "3.88.0", + "@payloadcms/translations": "3.88.0", "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "graphql": "^16.8.1", - "image-size": "^1.2.1", "lucide-react": "^0.511.0", - "next": "15.5.9", - "payload": "^3.80.0", - "react": "19.2.1", - "react-dom": "19.2.1", + "next": "16.3.4", + "payload": "3.88.0", + "pdf-lib": "^1.17.1", + "react": "19.2.8", + "react-dom": "19.2.8", + "sharp": "0.35.4", "tailwind-merge": "^3.3.0", "tsconfig-paths": "^4.2.0" }, @@ -47,11 +52,14 @@ "autoprefixer": "^10.4.21", "esbuild": "^0.25.2", "eslint": "^9", - "eslint-config-next": "15.2.1", + "eslint-config-next": "16.3.4", "postcss": "^8.5.3", "tailwindcss": "^4.1.7", "tw-animate-css": "^1.3.0", "typescript": "^5" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "resolutions": { + "**/@esbuild-kit/core-utils/esbuild": "0.25.12" + } } diff --git a/scripts/build-career-transfer.mjs b/scripts/build-career-transfer.mjs new file mode 100644 index 0000000..5684b0a --- /dev/null +++ b/scripts/build-career-transfer.mjs @@ -0,0 +1,8 @@ +import { build } from 'esbuild' + +await build({ + entryPoints: ['scripts/career-transfer/server-cli.mjs'], + outfile: 'scripts/.career-transfer/server.mjs', + bundle: true, platform: 'node', format: 'esm', packages: 'external', + banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" }, +}) diff --git a/scripts/career-transfer-server.mjs b/scripts/career-transfer-server.mjs new file mode 100644 index 0000000..b981d27 --- /dev/null +++ b/scripts/career-transfer-server.mjs @@ -0,0 +1,10 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +// Inherit only DB and application identity, never integration credentials or .env loading. +const env = Object.fromEntries(['PATH', 'HOME', 'TMPDIR', 'DATABASE_URI', 'PAYLOAD_SECRET', 'NEXT_PUBLIC_SERVER_URL'].flatMap(key => process.env[key] === undefined ? [] : [[key, process.env[key]]])) +Object.assign(env, { NODE_ENV: 'production', NEXT_TELEMETRY_DISABLED: '1' }) +const result = spawnSync(process.execPath, ['--import', './css-loader-register.mjs', './scripts/.career-transfer/server.mjs', ...process.argv.slice(2)], { cwd: root, env, stdio: 'inherit' }) +process.exitCode = result.status ?? 1 diff --git a/scripts/career-transfer.mjs b/scripts/career-transfer.mjs new file mode 100644 index 0000000..47128f8 --- /dev/null +++ b/scripts/career-transfer.mjs @@ -0,0 +1,37 @@ +import { build } from 'esbuild' +import { spawnSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { mkdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +// This runner deliberately supports isolated local rehearsal databases only. +const database = process.argv[2] +if (!/^frt_transfer_(candidate|staging)$/.test(database ?? '')) { + throw new Error('Usage: node scripts/career-transfer.mjs frt_transfer_{candidate|staging} [audit-filename.json]') +} +const root = path.resolve('.local-verification') +await mkdir(root, { recursive: true, mode: 0o700 }) +const credentials = Object.fromEntries((await readFile(path.join(root, 'postgres.env'), 'utf8')).trim().split('\n').map(line => { + const separator = line.indexOf('=') + return [line.slice(0, separator), line.slice(separator + 1)] +})) +const uri = new URL(`postgresql://127.0.0.1:55441/${database}`) +uri.username = credentials.POSTGRES_USER +uri.password = credentials.POSTGRES_PASSWORD +const outfile = path.join(root, 'career-transfer-runner.mjs') +await build({ + entryPoints: ['scripts/career-transfer/cli.mjs'], outfile, + bundle: true, platform: 'node', format: 'esm', packages: 'external', + banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" }, +}) +const result = spawnSync(process.execPath, ['--import', './css-loader-register.mjs', outfile, ...process.argv.slice(3)], { + stdio: 'inherit', + env: { + PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, + NODE_ENV: 'production', NEXT_TELEMETRY_DISABLED: '1', + DATABASE_URI: uri.href, PAYLOAD_SECRET: randomBytes(32).toString('hex'), + FRT_TRANSFER_DATABASE: database, FRT_TRANSFER_ROOT: root, + CV_STORAGE_DIR: path.join(root, database, 'private/cvs'), + }, +}) +process.exitCode = result.status ?? 1 diff --git a/scripts/career-transfer/cli.mjs b/scripts/career-transfer/cli.mjs new file mode 100644 index 0000000..cae2f90 --- /dev/null +++ b/scripts/career-transfer/cli.mjs @@ -0,0 +1,79 @@ +import { postgresAdapter } from '@payloadcms/db-postgres' +import { getPayload } from 'payload' +import path from 'node:path' +import { mkdir, writeFile, readFile } from 'node:fs/promises' +import appConfig from '../../src/payload.config' +import { migrations } from '../../src/migrations' +import { loadBundle, buildPlan, applyPlan } from './transfer.mjs' +import { verifyGuards } from './verify-guards.mjs' + +process.umask(0o077) +const root = process.env.FRT_TRANSFER_ROOT +const database = process.env.FRT_TRANSFER_DATABASE +const uri = new URL(process.env.DATABASE_URI) +if (uri.hostname !== '127.0.0.1' || uri.port !== '55441' || uri.pathname !== `/${database}` || !/^frt_transfer_(candidate|staging)$/.test(database)) { + throw new Error('Only the dedicated local rehearsal databases are allowed') +} +// No environment files are loaded by this runner. Block fetch even if a future hook adds an integration. +globalThis.fetch = async () => { throw new Error('External requests are disabled during content rehearsal') } +const config = await appConfig +config.telemetry = false +config.db = postgresAdapter({ pool: { connectionString: uri.href }, push: false, disableCreateDatabase: true, migrationDir: path.resolve('src/migrations') }) +config.email = () => ({ name: 'disabled', defaultFromAddress: 'disabled@localhost', defaultFromName: 'Disabled', sendEmail: async () => { throw new Error('Email is disabled during rehearsal') } }) +for (const collection of config.collections) { + if (collection.upload) collection.upload.staticDir = path.join(root, database, collection.slug === 'application-cvs' ? 'private/cvs' : collection.slug) +} +await mkdir(path.join(root, database, 'media'), { recursive: true }) +const payload = await getPayload({ config }) +try { + const command = process.argv[2] + if (command === 'migrate') { + if (database !== 'frt_transfer_candidate') throw new Error('Only the candidate may be migrated') + await payload.db.migrate({ migrations }) + console.log('Migrations completed') + } else if (command === 'export') { + const recruitment = await payload.find({ collection: 'recruitment', depth: 0, pagination: false, sort: 'id' }) + const settings = await payload.findGlobal({ slug: 'career-settings', depth: 0 }) + const media = await payload.find({ collection: 'media', depth: 0, pagination: false, sort: 'id' }) + const result = { database, recruitment: recruitment.docs, settings, media: media.docs } + await writeFile(path.join(root, `${database}.json`), JSON.stringify(result, null, 2)) + console.log(JSON.stringify({ database, groups: result.recruitment.map(doc => ({ id: doc.id, name: doc.groupName, positions: doc.positions.length })), faqs: settings.faqs?.length ?? 0, questions: settings.formQuestions?.length ?? 0, media: media.docs.length })) + } else if (['plan', 'apply', 'verify-guards'].includes(command)) { + if (database !== 'frt_transfer_candidate') throw new Error('Only the candidate may receive an import') + const bundlePath = path.join(root, 'bundle/bundle.json') + const bundle = await loadBundle(bundlePath) + const mappings = JSON.parse(await readFile(path.join(root, 'group-mapping.json'))) + const planPath = path.join(root, 'import-plan.json') + if (command === 'verify-guards') { + console.log(JSON.stringify(await verifyGuards(payload, root, bundle, bundlePath, mappings))) + } else if (command === 'plan') { + const plan = await buildPlan(payload, bundle, mappings) + await writeFile(planPath, JSON.stringify(plan, null, 2)) + console.log(JSON.stringify(plan, null, 2)) + } else { + const plan = JSON.parse(await readFile(planPath)) + console.log(JSON.stringify(await applyPlan(payload, bundle, bundlePath, mappings, plan, path.join(root, 'import-receipt.json')), null, 2)) + } + } else if (command === 'audit') { + const tables = await payload.db.pool.query("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename") + const result = {} + for (const { tablename } of tables.rows) { + const table = `"${tablename.replaceAll('"', '""')}"` + const query = await payload.db.pool.query(`SELECT count(*)::int AS count, md5(coalesce(string_agg(to_jsonb(t)::text, E'\\n' ORDER BY to_jsonb(t)::text), '')) AS hash FROM ${table} t`) + result[tablename] = query.rows[0] + } + const filename = process.argv[3] + if (!filename || path.basename(filename) !== filename || !filename.endsWith('.json')) throw new Error('Provide an audit JSON filename') + await writeFile(path.join(root, filename), JSON.stringify(result, null, 2)) + console.log(`Audited ${tables.rows.length} tables -> ${filename}`) + } else { + throw new Error(`Unknown command: ${command}`) + } +} catch (error) { + console.error(error) + process.exitCode = 1 +} finally { + await payload.destroy() +} +// Payload's own CLI also exits explicitly: adapter.destroy does not close its pool. +process.exit(process.exitCode ?? 0) diff --git a/scripts/career-transfer/content.mjs b/scripts/career-transfer/content.mjs new file mode 100644 index 0000000..49fb29b --- /dev/null +++ b/scripts/career-transfer/content.mjs @@ -0,0 +1,58 @@ +import { createHash } from 'node:crypto' + +const groupFields = ['groupName', 'groupNameEng', 'image', 'order', 'description', 'descriptionEng', 'positions'] +const settingsFields = ['intro', 'introEng', 'applicationsClosedText', 'applicationsClosedTextEng', 'faqs', 'formQuestions', 'applicationMode', 'googleFormUrl', 'spreadsheetUrl', 'applicationsOpen'] +const rowArrays = new Set(['positions', 'sections', 'faqs', 'formQuestions', 'options']) + +// Strip Payload row IDs only, never IDs/keys embedded in rich text or question keys. +function clean(value, isRow = false) { + if (Array.isArray(value)) return value.map(item => clean(item)) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value).filter(([key]) => !(isRow && key === 'id')).map(([key, item]) => [key, + rowArrays.has(key) && Array.isArray(item) ? item.map(row => clean(row, true)) : clean(item), + ])) +} +export function content(doc, kind) { + return clean(Object.fromEntries((kind === 'group' ? groupFields : settingsFields).map(key => [key, doc[key] ?? null]))) +} +export function canonical(value) { + if (Array.isArray(value)) return value.map(canonical) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) +} +export const hash = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex') +export const fileHash = buffer => createHash('sha256').update(buffer).digest('hex') + +export function mediaReferences(value, result = new Set()) { + if (!value || typeof value !== 'object') return result + if (value.relationTo) { + if (value.relationTo !== 'media') throw new Error(`Unmapped rich-text relationship: ${value.relationTo}`) + const id = typeof value.value === 'object' ? value.value?.id : value.value + if (!Number.isInteger(id)) throw new Error('Invalid rich-text media reference') + result.add(id) + } + for (const item of Object.values(value)) mediaReferences(item, result) + return result +} +export function remapMedia(value, mapping) { + if (Array.isArray(value)) return value.map(item => remapMedia(item, mapping)) + if (!value || typeof value !== 'object') return value + const result = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, remapMedia(item, mapping)])) + if (result.relationTo) { + if (result.relationTo !== 'media') throw new Error(`Unsupported relationship: ${result.relationTo}`) + const oldID = typeof result.value === 'object' ? result.value.id : result.value + if (!mapping[oldID]) throw new Error(`Missing media mapping: ${oldID}`) + result.value = mapping[oldID] + } + return result +} +export function validateMapping(source, target, mappings) { + if (mappings.length !== source.length || new Set(mappings.map(x => x.sourceID)).size !== source.length) throw new Error('Every source group needs exactly one mapping') + if (new Set(mappings.map(x => x.targetID)).size !== mappings.length) throw new Error('Target group mappings must be unique') + if (target.some(doc => !mappings.some(mapping => mapping.targetID === doc.id))) throw new Error('Unmapped target groups require an explicit removal plan') + for (const mapping of mappings) { + const s = source.find(x => x.id === mapping.sourceID) + const t = target.find(x => x.id === mapping.targetID) + if (!s || !t || s.groupName !== mapping.sourceName || t.groupName !== mapping.targetName) throw new Error(`Group mapping no longer matches: ${mapping.sourceID} -> ${mapping.targetID}`) + } +} diff --git a/scripts/career-transfer/server-cli.mjs b/scripts/career-transfer/server-cli.mjs new file mode 100644 index 0000000..812508c --- /dev/null +++ b/scripts/career-transfer/server-cli.mjs @@ -0,0 +1,66 @@ +import { postgresAdapter } from '@payloadcms/db-postgres' +import { getPayload } from 'payload' +import { readFile, writeFile, stat } from 'node:fs/promises' +import path from 'node:path' +import appConfig from '../../src/payload.config' +import { migrations } from '../../src/migrations' +import { hash } from './content.mjs' +import { parseOptions } from './server-options.mjs' +import { loadBundle, buildPlan, applyPlan } from './transfer.mjs' + +process.umask(0o077) +globalThis.fetch = async () => { throw new Error('External requests are disabled during content import') } +let payload +try { + const options = parseOptions(process.argv.slice(2), process.env) + const bundle = await loadBundle(options.bundle) + const mappings = JSON.parse(await readFile(options.mapping, 'utf8')) + const approvedPlan = options.command === 'apply' ? JSON.parse(await readFile(options.plan, 'utf8')) : null + if (approvedPlan) { + const { sha256, ...data } = approvedPlan + if (sha256 !== options['plan-sha256'] || hash(data) !== sha256 || !approvedPlan.target) throw new Error('Plan hash mismatch or missing server target identity') + } + const output = options.command === 'plan' ? options.out : options.receipt + if (!(await stat(path.dirname(output))).isDirectory()) throw new Error('Output directory does not exist') + try { await stat(output); throw new Error('Output file already exists; preserve earlier plans and receipts') } + catch (error) { if (error.code !== 'ENOENT') throw error } + const mediaDir = path.resolve('media') + if (!(await stat(mediaDir)).isDirectory()) throw new Error('The application media directory must be mounted') + const config = await appConfig + config.telemetry = false + config.db = postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI }, push: false, disableCreateDatabase: true, migrationDir: path.resolve('src/migrations') }) + config.email = () => ({ name: 'disabled', defaultFromAddress: 'disabled@localhost', defaultFromName: 'Disabled', sendEmail: async () => { throw new Error('Email is disabled during content import') } }) + for (const collection of config.collections) { + if (collection.upload) collection.upload.staticDir = collection.slug === 'media' ? mediaDir : path.resolve('private/cvs') + } + payload = await getPayload({ config }) + const applied = (await payload.db.pool.query('SELECT name FROM payload_migrations')).rows.map(row => row.name) + const pending = migrations.filter(migration => !applied.includes(migration.name)).map(migration => migration.name) + if (pending.length) throw new Error(`Database migrations must run before import: ${pending.join(', ')}`) + const uri = new URL(process.env.DATABASE_URI) + const actual = (await payload.db.pool.query('SELECT current_database() AS database, current_user AS role, inet_server_addr()::text AS "serverAddress", inet_server_port() AS "serverPort"')).rows[0] + if (actual.database !== options['expect-database']) throw new Error('Connected database differs from the expected database') + const targetIdentity = { environment: options.environment, origin: new URL(process.env.NEXT_PUBLIC_SERVER_URL).origin, host: uri.hostname, port: uri.port || '5432', ...actual } + if (approvedPlan && hash(approvedPlan.target) !== hash(targetIdentity)) throw new Error('Connected server identity does not match the approved plan') + if (options.command === 'plan') { + const plan = await buildPlan(payload, bundle, mappings, undefined, targetIdentity) + await writeFile(output, JSON.stringify(plan, null, 2), { flag: 'wx', mode: 0o600 }) + console.log(JSON.stringify({ plan: output, sha256: plan.sha256, target: targetIdentity, groupsToUpdate: plan.groups.filter(x => x.action === 'update').length, mediaToCreate: plan.assets.filter(x => x.action === 'create').length, settings: plan.settings }, null, 2)) + } else { + await writeFile(output, JSON.stringify({ status: 'prepared', planHash: approvedPlan.sha256, target: targetIdentity }, null, 2), { flag: 'wx', mode: 0o600 }) + try { + await applyPlan(payload, bundle, options.bundle, mappings, approvedPlan, output, { targetIdentity }) + console.log(JSON.stringify({ status: 'committed', receipt: output, target: targetIdentity })) + } catch (error) { + const receipt = JSON.parse(await readFile(output, 'utf8')) + if (receipt.status === 'prepared') await writeFile(output, JSON.stringify({ ...receipt, status: 'failed-before-commit' }, null, 2)) + throw error + } + } +} catch (error) { + console.error(error.message) + process.exitCode = 1 +} finally { + if (payload) await payload.destroy() +} +process.exit(process.exitCode ?? 0) diff --git a/scripts/career-transfer/server-options.mjs b/scripts/career-transfer/server-options.mjs new file mode 100644 index 0000000..6d29e5c --- /dev/null +++ b/scripts/career-transfer/server-options.mjs @@ -0,0 +1,28 @@ +import path from 'node:path' + +export function parseOptions(argv, env) { + const [command, ...args] = argv + if (!['plan', 'apply'].includes(command)) throw new Error('Usage: career-transfer-server.mjs --environment --expect-database --expect-origin --bundle --mapping [--out | --plan --plan-sha256 --receipt ]') + const allowed = new Set(['environment', 'expect-database', 'expect-origin', 'bundle', 'mapping', ...(command === 'plan' ? ['out'] : ['plan', 'plan-sha256', 'receipt'])]) + const options = { command } + for (let i = 0; i < args.length; i += 2) { + const key = args[i]?.slice(2) + if (!args[i]?.startsWith('--') || !allowed.has(key) || Object.hasOwn(options, key) || !args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`Invalid or duplicate option: ${args[i]}`) + options[key] = args[i + 1] + } + for (const key of allowed) if (!options[key]) throw new Error(`Missing --${key}`) + if (!['production', 'staging', 'rehearsal'].includes(options.environment)) throw new Error('Invalid environment') + if (!env.DATABASE_URI || !env.PAYLOAD_SECRET || !env.NEXT_PUBLIC_SERVER_URL) throw new Error('DATABASE_URI, PAYLOAD_SECRET and NEXT_PUBLIC_SERVER_URL are required') + const database = new URL(env.DATABASE_URI) + if (!['postgres:', 'postgresql:'].includes(database.protocol)) throw new Error('PostgreSQL DATABASE_URI required') + if (decodeURIComponent(database.pathname.slice(1)) !== options['expect-database']) throw new Error('Database does not match --expect-database') + const expectedOrigin = new URL(options['expect-origin']) + const origin = new URL(env.NEXT_PUBLIC_SERVER_URL) + if (!['http:', 'https:'].includes(origin.protocol) || expectedOrigin.origin !== origin.origin || expectedOrigin.username || expectedOrigin.password || origin.username || origin.password) throw new Error('Application origin does not match --expect-origin') + if (expectedOrigin.pathname !== '/' || expectedOrigin.search || expectedOrigin.hash) throw new Error('--expect-origin must contain only the origin') + if (command === 'apply' && !/^[a-f0-9]{64}$/.test(options['plan-sha256'])) throw new Error('An exact SHA-256 plan hash is required') + for (const key of ['bundle', 'mapping', 'out', 'plan', 'receipt']) if (options[key]) options[key] = path.resolve(options[key]) + const files = ['bundle', 'mapping', 'out', 'plan', 'receipt'].flatMap(key => options[key] ? [options[key]] : []) + if (new Set(files).size !== files.length) throw new Error('Input and output paths must be distinct') + return options +} diff --git a/scripts/career-transfer/transfer.mjs b/scripts/career-transfer/transfer.mjs new file mode 100644 index 0000000..54861d0 --- /dev/null +++ b/scripts/career-transfer/transfer.mjs @@ -0,0 +1,148 @@ +import { createLocalReq, initTransaction, commitTransaction, killTransaction } from 'payload' +import { sql } from '@payloadcms/db-postgres' +import { readFile, writeFile, rm, access } from 'node:fs/promises' +import path from 'node:path' +import { content, fileHash, hash, remapMedia, validateMapping, mediaReferences } from './content.mjs' + +export async function readTarget(payload, req) { + const recruitment = await payload.find({ collection: 'recruitment', depth: 0, pagination: false, sort: 'id', req }) + const settings = await payload.findGlobal({ slug: 'career-settings', depth: 0, req }) + const media = await payload.find({ collection: 'media', depth: 0, pagination: false, sort: 'id', req }) + return { recruitment: recruitment.docs, settings, media: media.docs } +} + +export function targetFingerprint(target) { + return hash({ + recruitment: target.recruitment.map(doc => ({ id: doc.id, updatedAt: doc.updatedAt, data: content(doc, 'group') })), + settings: { data: content(target.settings, 'settings'), updatedAt: target.settings.updatedAt }, + media: target.media.map(({ id, filename, alt, filesize, updatedAt }) => ({ id, filename, alt, filesize, updatedAt })), + }) +} + +export async function loadBundle(filename) { + const bundle = JSON.parse(await readFile(filename, 'utf8')) + const { sha256, ...data } = bundle + if (bundle.version !== 1 || hash(data) !== sha256) throw new Error('Bundle checksum mismatch') + const refs = mediaReferences([bundle.recruitment, bundle.settings]) + for (const doc of bundle.recruitment) if (doc.image) refs.add(doc.image) + if (new Set(bundle.assets.map(x => x.sourceID)).size !== bundle.assets.length || refs.size !== bundle.assets.length || [...refs].some(id => !bundle.assets.some(x => x.sourceID === id))) throw new Error('Incomplete or duplicate media assets') + for (const asset of bundle.assets) { + if (path.basename(asset.filename) !== asset.filename) throw new Error('Invalid asset filename') + const bytes = await readFile(path.join(path.dirname(filename), 'media', asset.filename)) + if (bytes.length !== asset.size || fileHash(bytes) !== asset.sha256) throw new Error(`Asset checksum mismatch: ${asset.filename}`) + } + return bundle +} + +export async function buildPlan(payload, bundle, mappings, req, targetIdentity) { + const target = await readTarget(payload, req) + // A completed run may have renamed a target group to its source name. + validateMapping(bundle.recruitment, target.recruitment, mappings.map(m => ({ ...m, + targetName: target.recruitment.find(t => t.id === m.targetID)?.groupName === m.sourceName ? m.sourceName : m.targetName, + }))) + const mediaDir = payload.collections.media.config.upload.staticDir + const assets = [] + for (const asset of bundle.assets) { + const importName = `career-${asset.sha256}${path.extname(asset.filename).toLowerCase()}` + const candidates = target.media.filter(doc => [asset.filename, importName].includes(doc.filename)) + const matches = [] + for (const doc of candidates) { + const bytes = await readFile(path.join(mediaDir, doc.filename)) + if (fileHash(bytes) === asset.sha256 && doc.alt === asset.alt) matches.push(doc) + } + if (matches.length > 1) throw new Error(`Ambiguous target image: ${asset.filename}`) + if (!matches.length && candidates.some(x => x.filename === importName)) throw new Error(`Conflicting import image: ${importName}`) + assets.push({ sourceID: asset.sourceID, action: matches.length ? 'reuse' : 'create', targetID: matches[0]?.id ?? null, filename: matches[0]?.filename ?? importName, sha256: asset.sha256 }) + } + const mediaMap = Object.fromEntries(assets.map(asset => [asset.sourceID, asset.targetID ?? `new:${asset.sha256}`])) + const groups = mappings.map(mapping => { + const source = bundle.recruitment.find(doc => doc.id === mapping.sourceID) + const before = content(target.recruitment.find(doc => doc.id === mapping.targetID), 'group') + const after = remapMedia(content(source, 'group'), mediaMap) + after.image = source.image ? mediaMap[source.image] : null + return { sourceID: mapping.sourceID, targetID: mapping.targetID, name: source.groupName, + action: hash(before) === hash(after) ? 'unchanged' : 'update', + changedFields: Object.keys(after).filter(key => hash(before[key]) !== hash(after[key])), + positionsBefore: before.positions.length, positionsAfter: after.positions.length, + oldPositions: before.positions.map(x => x.positionName), newPositions: after.positions.map(x => x.positionName), + } + }) + const beforeSettings = content(target.settings, 'settings') + const afterSettings = remapMedia(content(bundle.settings, 'settings'), mediaMap) + const settings = { action: hash(beforeSettings) === hash(afterSettings) ? 'unchanged' : 'update', + changedFields: Object.keys(afterSettings).filter(key => hash(beforeSettings[key]) !== hash(afterSettings[key])), + authority: 'staging', + sourceIntegrationConfigured: !!bundle.settings.spreadsheetUrl, + targetIntegrationConfigured: !!target.settings.spreadsheetUrl, + } + const data = { version: 1, bundleHash: bundle.sha256, targetHash: targetFingerprint(target), mappingHash: hash(mappings), assets, groups, settings, ...(targetIdentity ? { target: targetIdentity } : {}) } + return { ...data, sha256: hash(data) } +} + +export async function applyPlan(payload, bundle, bundlePath, mappings, approvedPlan, receiptPath, { targetIdentity } = {}) { + const { sha256, ...planData } = approvedPlan + if (hash(planData) !== sha256 || approvedPlan.bundleHash !== bundle.sha256 || approvedPlan.mappingHash !== hash(mappings)) throw new Error('Plan checksum or input mismatch') + const req = await createLocalReq({ context: { skipSheetSync: true } }, payload) + const createdFiles = new Set() + const mediaDir = payload.collections.media.config.upload.staticDir + let committed = false + let commitAttempted = false + const receipt = { planHash: sha256, bundleHash: bundle.sha256, createdMedia: [], updatedGroups: [], updatedSettings: false, ...(targetIdentity ? { target: targetIdentity } : {}) } + try { + await initTransaction(req) + const transaction = payload.db.sessions[await req.transactionID]?.db + if (!transaction) throw new Error('A real PostgreSQL transaction is required') + await transaction.execute(sql`SET LOCAL lock_timeout = '5s'`) + await transaction.execute(sql`LOCK TABLE recruitment, recruitment_positions, recruitment_positions_sections, career_settings, career_settings_faqs, career_settings_form_questions, career_settings_form_questions_options, media IN SHARE ROW EXCLUSIVE MODE`) + const currentPlan = await buildPlan(payload, bundle, mappings, req, targetIdentity) + if (currentPlan.sha256 !== sha256) throw new Error('Target changed since planning; generate and review a new plan') + const mediaMap = {} + for (const action of approvedPlan.assets) { + if (action.action === 'reuse') { mediaMap[action.sourceID] = action.targetID; continue } + const asset = bundle.assets.find(x => x.sourceID === action.sourceID) + const destination = path.join(mediaDir, action.filename) + try { await access(destination); throw new Error(`Untracked target file already exists: ${action.filename}`) } + catch (error) { if (error.code !== 'ENOENT') throw error } + createdFiles.add(destination) + const data = await readFile(path.join(path.dirname(bundlePath), 'media', asset.filename)) + const doc = await payload.create({ collection: 'media', data: { alt: asset.alt }, + file: { data, name: action.filename, mimetype: asset.mimeType, size: data.length }, req }) + createdFiles.add(path.join(mediaDir, doc.filename)) + if (fileHash(await readFile(path.join(mediaDir, doc.filename))) !== asset.sha256) throw new Error(`Upload changed file content: ${asset.filename}`) + mediaMap[action.sourceID] = doc.id + receipt.createdMedia.push({ sourceID: action.sourceID, targetID: doc.id, filename: doc.filename }) + } + for (const action of approvedPlan.groups) { + if (action.action === 'unchanged') continue + const source = bundle.recruitment.find(doc => doc.id === action.sourceID) + const data = remapMedia(content(source, 'group'), mediaMap) + data.image = source.image ? mediaMap[source.image] : null + await payload.update({ collection: 'recruitment', id: action.targetID, data, depth: 0, req }) + receipt.updatedGroups.push(action.targetID) + } + if (approvedPlan.settings.action !== 'unchanged') { + await payload.updateGlobal({ slug: 'career-settings', data: remapMedia(content(bundle.settings, 'settings'), mediaMap), depth: 0, req }) + receipt.updatedSettings = true + } + const afterPlan = await buildPlan(payload, bundle, mappings, req, targetIdentity) + if (afterPlan.groups.some(x => x.action !== 'unchanged') || afterPlan.assets.some(x => x.action !== 'reuse') || afterPlan.settings.action !== 'unchanged') throw new Error('Imported content differs from the bundle') + receipt.afterHash = afterPlan.targetHash + // Record pending outcome before commit; never report committed until PostgreSQL confirms it. + await writeFile(receiptPath, JSON.stringify({ ...receipt, status: 'pending-commit' }, null, 2)) + commitAttempted = true + await commitTransaction(req) + committed = true + await writeFile(receiptPath, JSON.stringify({ ...receipt, status: 'committed' }, null, 2)) + return receipt + } catch (error) { + if (!committed && !commitAttempted) { + await killTransaction(req) + for (const filename of createdFiles) await rm(filename, { force: true }) + } + if (!committed && commitAttempted) { + // A lost COMMIT response does not prove rollback. Keep files for reconciliation. + await writeFile(receiptPath, JSON.stringify({ ...receipt, status: 'commit-uncertain' }, null, 2)).catch(() => {}) + } + throw error + } +} diff --git a/scripts/career-transfer/verify-guards.mjs b/scripts/career-transfer/verify-guards.mjs new file mode 100644 index 0000000..66d7203 --- /dev/null +++ b/scripts/career-transfer/verify-guards.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, readFile, readdir, writeFile, symlink, rm } from 'node:fs/promises' +import path from 'node:path' +import { applyPlan, buildPlan, readTarget, targetFingerprint } from './transfer.mjs' +import { fileHash, hash } from './content.mjs' + +export async function verifyGuards(payload, root, bundle, bundlePath, mappings) { + const before = targetFingerprint(await readTarget(payload)) + const mediaDir = payload.collections.media.config.upload.staticDir + const beforeFiles = (await readdir(mediaDir)).sort() + const directory = await mkdtemp(path.join(root, 'guard-test-')) + try { + const stale = await buildPlan(payload, bundle, mappings) + stale.targetHash = 'deliberately-stale' + const { sha256: ignored, ...staleData } = stale + void ignored + stale.sha256 = hash(staleData) + await assert.rejects(() => applyPlan(payload, bundle, bundlePath, mappings, stale, path.join(directory, 'stale-receipt.json')), /Target changed since planning/) + + // Create a valid, distinct JPEG first, then fail validation on the LAST group. + // This verifies rollback of earlier updates and cleanup of newly written files. + const invalid = structuredClone(bundle) + await mkdir(path.join(directory, 'media')) + for (let i = 0; i < invalid.assets.length; i++) { + const asset = invalid.assets[i] + const source = path.join(path.dirname(bundlePath), 'media', asset.filename) + const target = path.join(directory, 'media', asset.filename) + if (i === 0) { + const bytes = Buffer.concat([await readFile(source), Buffer.from('\nlocal rollback verification\n')]) + await writeFile(target, bytes) + asset.sha256 = fileHash(bytes) + asset.size = bytes.length + } else await symlink(source, target) + } + invalid.recruitment[0].order = 98765 + invalid.recruitment.at(-1).description = null + const { sha256: oldHash, ...invalidData } = invalid + void oldHash + invalid.sha256 = hash(invalidData) + const plan = await buildPlan(payload, invalid, mappings) + assert.ok(plan.assets.some(x => x.action === 'create')) + await assert.rejects(() => applyPlan(payload, invalid, path.join(directory, 'bundle.json'), mappings, plan, path.join(directory, 'rollback-receipt.json')), error => error.name === 'ValidationError') + assert.equal(targetFingerprint(await readTarget(payload)), before) + assert.deepEqual((await readdir(mediaDir)).sort(), beforeFiles) + const result = { stalePlanRejected: true, databaseRollback: true, newFileCleanup: true } + await writeFile(path.join(root, 'guard-results.json'), JSON.stringify(result, null, 2)) + return result + } finally { + await rm(directory, { recursive: true, force: true }) + } +} diff --git a/scripts/deploy-image.sh b/scripts/deploy-image.sh new file mode 100755 index 0000000..3f5128c --- /dev/null +++ b/scripts/deploy-image.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +usage() { + echo "Usage: $0 IMAGE_REF COMPOSE_PROJECT APP_ENV_FILE HEALTH_URL BACKUP_DATABASE [COMPOSE_OVERRIDE]" >&2 +} + +if [[ $# -lt 5 || $# -gt 6 ]]; then + usage + exit 2 +fi + +image_ref="$1" +compose_project="$2" +app_env_file="$3" +health_url="$4" +backup_database="$5" +compose_override="${6:-}" + +if [[ ! "$image_ref" =~ ^ghcr\.io/[a-z0-9_.-]+/[a-z0-9_.-]+(@sha256:[a-f0-9]{64}|:[A-Za-z0-9_.-]+)$ ]]; then + echo "Invalid GHCR image reference: $image_ref" >&2 + exit 2 +fi + +if [[ ! "$compose_project" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then + echo "Invalid Compose project name: $compose_project" >&2 + exit 2 +fi + +if [[ "$app_env_file" = /* || "$app_env_file" == *".."* ]]; then + echo "APP_ENV_FILE must be a relative path inside the deployment directory." >&2 + exit 2 +fi + +if [[ ! "$health_url" =~ ^http://127\.0\.0\.1:[0-9]+/api/health$ ]]; then + echo "HEALTH_URL must target the local environment health endpoint." >&2 + exit 2 +fi + +if [[ "$backup_database" != "true" && "$backup_database" != "false" ]]; then + echo "BACKUP_DATABASE must be true or false." >&2 + exit 2 +fi + +if [[ -n "$compose_override" && ( "$compose_override" = /* || "$compose_override" == *".."* ) ]]; then + echo "COMPOSE_OVERRIDE must be a relative path inside the deployment directory." >&2 + exit 2 +fi + +if [[ ! -f "$app_env_file" ]]; then + echo "Missing application environment file: $app_env_file" >&2 + exit 1 +fi + +if [[ ! -f docker-compose.yml ]]; then + echo "docker-compose.yml is missing from $(pwd)." >&2 + exit 1 +fi + +if [[ -n "$compose_override" && ! -f "$compose_override" ]]; then + echo "Compose override is missing: $compose_override" >&2 + exit 1 +fi + +candidate_env=".env.deploy.next" +active_env=".env.deploy" +rollback_env=".env.deploy.rollback" +previous_image="" + +cleanup() { + rm -f "$candidate_env" "$rollback_env" +} + +trap cleanup EXIT + +write_deploy_env() { + local target_file="$1" + local target_image="$2" + + umask 077 + { + printf 'FRT_APP_IMAGE=%s\n' "$target_image" + printf 'APP_ENV_FILE=%s\n' "$app_env_file" + } > "$target_file" +} + +compose_with() { + local deploy_env="$1" + shift + + local args=( + docker compose + --env-file "$deploy_env" + -p "$compose_project" + -f docker-compose.yml + ) + + if [[ -n "$compose_override" ]]; then + args+=(-f "$compose_override") + fi + + "${args[@]}" "$@" +} + +write_deploy_env "$candidate_env" "$image_ref" + +current_container_id="$(compose_with "$candidate_env" ps -q app 2>/dev/null || true)" +if [[ -n "$current_container_id" ]]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "$current_container_id" 2>/dev/null || true)" +fi + +rollback_app() { + if [[ -z "$previous_image" ]]; then + echo "No previous application image is available for automatic rollback." >&2 + return 1 + fi + + echo "Rolling the application back to $previous_image" >&2 + write_deploy_env "$rollback_env" "$previous_image" + compose_with "$rollback_env" up -d --no-build --pull never --wait --wait-timeout 120 app +} + +echo "Pulling application image $image_ref" +compose_with "$candidate_env" pull app + +echo "Starting the environment database" +compose_with "$candidate_env" up -d --wait --wait-timeout 120 postgres + +if [[ "$backup_database" == "true" ]]; then + backup_dir="backups" + backup_path="$backup_dir/postgres-$(date -u +%Y%m%dT%H%M%SZ).dump" + backup_tmp="$backup_path.tmp" + mkdir -p "$backup_dir" + + echo "Creating production database backup at $backup_path" + # The variables are intentionally expanded inside the PostgreSQL container. + # shellcheck disable=SC2016 + if compose_with "$candidate_env" exec -T postgres sh -c \ + 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"' \ + > "$backup_tmp"; then + mv "$backup_tmp" "$backup_path" + else + rm -f "$backup_tmp" + exit 1 + fi + cv_backup_path="${backup_path%.dump}-cvs.tar.gz" + echo "Creating private CV backup at $cv_backup_path" + if compose_with "$candidate_env" run --rm -T --no-deps --entrypoint tar app \ + -C /app/private -czf - cvs > "$cv_backup_path.tmp"; then + mv "$cv_backup_path.tmp" "$cv_backup_path" + chmod 600 "$backup_path" "$cv_backup_path" + else + rm -f "$cv_backup_path.tmp" + exit 1 + fi +fi + +echo "Running Payload migrations with the candidate image" +printf 'y\n' | compose_with "$candidate_env" run --rm -T app yarn payload:migrate + +echo "Starting the candidate application" +if ! compose_with "$candidate_env" up -d --no-build --pull never --wait --wait-timeout 120 app; then + rollback_app || true + echo "Application startup failed. Database migrations are not rolled back automatically." >&2 + exit 1 +fi + +health_ok="false" +for _attempt in {1..12}; do + if curl --fail --silent --show-error --max-time 10 "$health_url" >/dev/null; then + health_ok="true" + break + fi + sleep 5 +done + +if [[ "$health_ok" != "true" ]]; then + rollback_app || true + echo "Health check failed for $health_url. Database migrations are not rolled back automatically." >&2 + exit 1 +fi + +mv "$candidate_env" "$active_env" +chmod 600 "$active_env" + +echo "Deployment completed successfully with $image_ref" diff --git a/scripts/prepare-career-bundle.mjs b/scripts/prepare-career-bundle.mjs new file mode 100644 index 0000000..6e56eac --- /dev/null +++ b/scripts/prepare-career-bundle.mjs @@ -0,0 +1,52 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileHash, hash, mediaReferences } from './career-transfer/content.mjs' + +process.umask(0o077) +const [stagingArchive, productionArchive] = process.argv.slice(2) +if (!stagingArchive || !productionArchive) throw new Error('Pass staging and production file archives') +const root = path.resolve('.local-verification') +const source = JSON.parse(await readFile(path.join(root, 'frt_transfer_staging.json'))) +const target = JSON.parse(await readFile(path.join(root, 'frt_transfer_candidate.json'))) +const ids = mediaReferences([source.recruitment, source.settings]) +source.recruitment.forEach(doc => { if (doc.image) ids.add(doc.image) }) +const media = [...ids].map(id => { + const doc = source.media.find(item => item.id === id) + if (!doc?.filename || path.basename(doc.filename) !== doc.filename) throw new Error(`Invalid media file: ${id}`) + return doc +}) +const bundleDir = path.join(root, 'bundle') +await mkdir(bundleDir, { recursive: true }) + +async function extract(archive, destination, filenames) { + await mkdir(path.join(destination, 'media'), { recursive: true }) + if (!filenames.length) return + // Extract exact regular files ourselves; no paths or links from tar may escape destination. + const python = `import tarfile,sys,os,shutil,json +names=set(json.loads(sys.argv[3])); found=set() +with tarfile.open(sys.argv[1], 'r|gz') as archive: + for member in archive: + if member.name in names: + if not member.isfile() or member.name in found: raise RuntimeError('Invalid archive member') + dest=os.path.join(sys.argv[2], member.name) + with archive.extractfile(member) as src, open(dest, 'xb') as out: shutil.copyfileobj(src,out) + found.add(member.name) +if found != names: raise RuntimeError('Missing archive members: '+str(names-found)) +` + const result = spawnSync('python3', ['-c', python, archive, destination, JSON.stringify(filenames.map(name => `media/${name}`))], { stdio: 'inherit' }) + if (result.status !== 0) throw new Error('Selective file extraction failed') +} +await extract(stagingArchive, bundleDir, media.map(doc => doc.filename)) +const existing = target.media.filter(doc => media.some(s => s.filename === doc.filename)) +await extract(productionArchive, path.join(root, 'frt_transfer_candidate'), existing.map(doc => doc.filename)) +const assets = [] +for (const doc of media) { + const bytes = await readFile(path.join(bundleDir, 'media', doc.filename)) + if (bytes.length !== doc.filesize) throw new Error(`File size differs from DB: ${doc.filename}`) + assets.push({ sourceID: doc.id, filename: doc.filename, alt: doc.alt, mimeType: doc.mimeType, size: bytes.length, sha256: fileHash(bytes) }) +} +const bundle = { version: 1, recruitment: source.recruitment, settings: source.settings, assets } +bundle.sha256 = hash(bundle) +await writeFile(path.join(bundleDir, 'bundle.json'), JSON.stringify(bundle, null, 2)) +console.log(JSON.stringify({ bundle: path.join(bundleDir, 'bundle.json'), files: assets.length, bytes: assets.reduce((n, x) => n + x.size, 0), sha256: bundle.sha256 })) diff --git a/scripts/test-career.mjs b/scripts/test-career.mjs new file mode 100644 index 0000000..17dc384 --- /dev/null +++ b/scripts/test-career.mjs @@ -0,0 +1,22 @@ +import { build } from "esbuild"; +import { mkdtemp, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +const directory = await mkdtemp(path.join(process.cwd(), ".career-tests-")); +try { + const outfile = path.join(directory, "career.test.cjs"); + await build({ + entryPoints: ["tests/career.test.ts"], + outfile, + bundle: true, + platform: "node", + format: "cjs", + packages: "external", + }); + const result = spawnSync(process.execPath, ["--test", outfile], { + stdio: "inherit", + }); + process.exitCode = result.status ?? 1; +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/src/app/(app)/globals.css b/src/app/(app)/globals.css index a478443..2c8490a 100644 --- a/src/app/(app)/globals.css +++ b/src/app/(app)/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "./karrier/career-theme.css"; @tailwind utilities; @plugin "@tailwindcss/typography"; @@ -143,6 +144,38 @@ margin-bottom: 0.5em; /* Kis térköz a listaelemek között */ } +/* Karrier oldal — pozíció szekcióinak felsorolása. + A szerkesztő a CMS-ben sima felsorolást ír, a sor elején emojival: az emoji a + felsorolásjel, ezért a pöttyöt elhagyjuk, és függő behúzással igazítjuk a + tördelt sorokat a szöveg alá. */ +.karrier-section-list ul, +.karrier-section-list ol { + list-style: none; + margin-left: 0; + padding-left: 0; +} + +.karrier-section-list li { + margin-bottom: 0.5em; + padding-left: 1.6em; + text-indent: -1.6em; + /* A sor elejére írt emoji után szűk lenne a sima szóköz — épp csak megnyitjuk. */ + word-spacing: 0.12em; +} + +.karrier-section-list li:last-child { + margin-bottom: 0; +} + +/* Ha a szerkesztő felsorolás helyett bekezdéseket ír, azok se lógjanak ki. */ +.karrier-section-list p { + margin-bottom: 0.5em; +} + +.karrier-section-list p:last-child { + margin-bottom: 0; +} + /* Egyéb formázások, ha szükséges (pl. linkek, címsorok, stb.) */ .rich-text-content a { color: theme('colors.frtRed'); diff --git a/src/app/(app)/kapcsolat/page.tsx b/src/app/(app)/kapcsolat/page.tsx index 66622bc..30a5728 100644 --- a/src/app/(app)/kapcsolat/page.tsx +++ b/src/app/(app)/kapcsolat/page.tsx @@ -50,12 +50,12 @@ export default async function ContactPage (props: { searchParams?: Promise - - {lang === 'en' ? 'Event Exhibition Request' : 'Rendezvényen való kiállítási igény'} - + {lang === 'en' ? (contact.exhibitionLabelEng || 'Event Exhibition Request') : (contact.exhibitionLabel || 'Rendezvényen való kiállítási igény')} + }
diff --git a/src/app/(app)/karrier/career-config.ts b/src/app/(app)/karrier/career-config.ts new file mode 100644 index 0000000..0245bb3 --- /dev/null +++ b/src/app/(app)/karrier/career-config.ts @@ -0,0 +1,22 @@ +import type { CareerSetting } from "@/payload-types" +import type { ApplicationConfig } from "./types" + +/** + * A "Karrier oldal" globális beállításból egyetlen, a nézetben közvetlenül + * használható objektumot állít elő, hogy a komponensekben ne kelljen újra és + * újra a nullable CMS mezőkkel bajlódni. + */ +export function resolveApplicationConfig( + settings: CareerSetting | null, + isEn: boolean, + closedTextFallback: string, +): ApplicationConfig { + const closedText = isEn ? settings?.applicationsClosedTextEng : settings?.applicationsClosedText + + return { + isOpen: settings?.applicationsOpen !== false, + useBuiltInForm: (settings?.applicationMode ?? "builtIn") === "builtIn", + googleFormUrl: settings?.googleFormUrl?.trim() || null, + closedText: closedText?.trim() || closedTextFallback, + } +} diff --git a/src/app/(app)/karrier/career-page-client.tsx b/src/app/(app)/karrier/career-page-client.tsx new file mode 100644 index 0000000..a82c9ec --- /dev/null +++ b/src/app/(app)/karrier/career-page-client.tsx @@ -0,0 +1,145 @@ +"use client" + +import type { CareerSetting, Recruitment } from "@/payload-types" +import { RichText } from "@payloadcms/richtext-lexical/react" +import { resolveFormConfig } from "@/lib/career-form" +import { resolveApplicationConfig } from "./career-config" +import ApplicationForm from "./components/application-form" +import ApplyButton from "./components/apply-button" +import CareerFaq from "./components/career-faq" +import CareerGroup from "./components/career-group" +import CareerSidebar from "./components/career-sidebar" +import { careerTexts, isEnglish } from "./translations" +import { useCareerPage } from "./use-career-page" + +interface CareerPageClientProps { + recruitmentData: Recruitment[] + careerSettings: CareerSetting | null + lang: string +} + +export default function CareerPageClient({ + recruitmentData, + careerSettings, + lang, +}: CareerPageClientProps) { + const isEn = isEnglish(lang) + const texts = careerTexts(isEn) + const application = resolveApplicationConfig( + careerSettings, + isEn, + texts.applicationsClosed, + ) + const intro = isEn ? careerSettings?.introEng : careerSettings?.intro + const faqs = careerSettings?.faqs ?? [] + const hasFaqs = faqs.some((item) => + Boolean( + (isEn ? item.questionEng : item.question)?.trim() && + (isEn ? item.answerEng : item.answer), + ), + ) + + const { + formRef, + openGroupIds, + openPositionKeys, + selectedPosition, + toggleGroup, + togglePosition, + openGroupAndScroll, + applyForPosition, + scrollToForm, + } = useCareerPage() + + return ( +
+
+
+

+ {texts.title} +

+ + {intro ? ( +
+ +
+ ) : ( +

+ {texts.subtitle} +

+ )} +
+
+
+
+ {recruitmentData.map((group) => ( + toggleGroup(group.id)} + onTogglePosition={togglePosition} + onApply={applyForPosition} + /> + ))} +
+ + + +
+ {!application.isOpen ? ( +
+

+ {application.closedText} +

+
+ ) : application.useBuiltInForm ? ( + + ) : ( + application.googleFormUrl && ( +
+

+ {texts.interested} +

+ +
+ ) + )} +
+
+ + +
+
+
+ ) +} diff --git a/src/app/(app)/karrier/career-theme.css b/src/app/(app)/karrier/career-theme.css new file mode 100644 index 0000000..1bbb856 --- /dev/null +++ b/src/app/(app)/karrier/career-theme.css @@ -0,0 +1,71 @@ +/* Career colors are independent of the legacy global utility overrides. */ +@theme inline { + --color-career-page: var(--career-page); + --color-career-surface: var(--career-surface); + --color-career-inset: var(--career-inset); + --color-career-hover: var(--career-hover); + --color-career-text: var(--career-text); + --color-career-muted: var(--career-muted); + --color-career-accent: var(--career-accent); + --color-career-accent-soft: var(--career-accent-soft); + --color-career-border: var(--career-border); + --color-career-input-border: var(--career-input-border); + --color-career-field: var(--career-field); + --color-career-action: var(--career-action); + --color-career-action-hover: var(--career-action-hover); + --color-career-on-action: var(--career-on-action); +} + +.career-page { + --career-page: #000000; + --career-surface: #230505; + --career-inset: #140404; + --career-hover: #3b1015; + --career-text: #fafafa; + --career-muted: #c7babb; + --career-accent: #ff8290; + --career-accent-soft: #421820; + --career-border: #663b40; + --career-input-border: #927478; + --career-field: #080808; + --career-action: #d51028; + --career-action-hover: #b20e21; + --career-on-action: #ffffff; + background-color: var(--career-page); + color: var(--career-text); +} + +html.light .career-page { + --career-page: #ffffff; + --career-surface: #f7f7f8; + --career-inset: #ffffff; + --career-hover: #f0e7e9; + --career-text: #211c22; + --career-muted: #59515a; + --career-accent: #b51025; + --career-accent-soft: #f9e4e7; + --career-border: #dad6d8; + --career-input-border: #82777b; + --career-field: #ffffff; +} + +/* CMS paragraphs and headings follow their surrounding text hierarchy. */ +.career-page .rich-text-content :is(p, h1, h2, h3, h4, h5, h6, li) { + color: inherit; +} + +.career-page .rich-text-content a { + color: var(--career-accent); + text-decoration: underline; + text-underline-offset: 3px; +} + +.career-page :is(button, a, input, textarea, select, [tabindex]):focus-visible { + outline: 2px solid var(--career-accent); + outline-offset: 3px; +} + +/* Inset outlines stay visible inside clipped accordion cards. */ +.career-page button[aria-expanded]:focus-visible { + outline-offset: -3px; +} diff --git a/src/app/(app)/karrier/components/application-form.tsx b/src/app/(app)/karrier/components/application-form.tsx new file mode 100644 index 0000000..5f1df3f --- /dev/null +++ b/src/app/(app)/karrier/components/application-form.tsx @@ -0,0 +1,403 @@ +"use client" + +import { AlertCircle, Loader2, Paperclip, X } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { + MAX_CV_SIZE, + validateAnswers, + type Answer, + type FormConfig, + type FormQuestion, +} from "@/lib/career-form" +import { applicationErrorText, applicationFormTexts } from "../translations" +import type { SelectedPosition } from "../types" +import { HoneypotField } from "./form-field" +import SuccessCheck from "./success-check" + +interface ApplicationFormProps { + lang: string + isEn: boolean + selectedPosition: SelectedPosition | null + initialConfig: FormConfig +} +const INPUT_CLASS = + "w-full rounded-lg border border-career-input-border bg-career-field px-4 py-2.5 text-career-text placeholder:text-career-muted focus:border-career-accent focus:outline-none focus:ring-1 focus:ring-career-accent" + +function Question({ + question: q, + value, + onChange, + isEn, + error, +}: { + question: FormQuestion + value?: Answer + onChange: (value: Answer) => void + isEn: boolean + error: boolean +}) { + const id = `application-${q.key}` + const label = isEn ? q.labelEng : q.label + const hint = isEn ? q.hintEng : q.hint + const labelContent = ( + <> + {label} + {q.required ? ( + " *" + ) : ( + + {" "} + ({isEn ? "optional" : "opcionális"}) + + )} + + ) + const common = { + id, + name: q.key, + "aria-invalid": error || undefined, + "aria-describedby": hint ? `${id}-hint` : undefined, + } + const onMultiChange = (key: string, checked: boolean) => { + const selected = Array.isArray(value) ? value : [] + onChange( + checked ? [...selected, key] : selected.filter((v) => v !== key), + ) + } + return ( +
+ {q.type === "multiselect" ? ( +
+ + {labelContent} + +
+ {q.options?.map((option) => ( + + ))} +
+
+ ) : q.type === "checkbox" ? ( + + ) : ( + <> + + {q.type === "textarea" ? ( +