diff --git a/.gitea/workflows/prod-tag-deploy.yml b/.gitea/workflows/prod-tag-deploy.yml index 7828559..e01704b 100644 --- a/.gitea/workflows/prod-tag-deploy.yml +++ b/.gitea/workflows/prod-tag-deploy.yml @@ -8,6 +8,7 @@ # # Variables: # REGISTRY_HOST Windows Docker push host, e.g. host.docker.internal:3000 +# (not gitea.wixur.ir unless nginx client_max_body_size 0) # REGISTRY_OWNER Gitea user/org for packages # CLONE_HOST git clone, e.g. 127.0.0.1:3000 # PROD_PUBLIC_BASE_URL https://nudentic.ir (no trailing slash) @@ -191,14 +192,20 @@ jobs: $infra = '${{ vars.PROD_INFRA_DIR }}'.Trim() if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' } $ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new') - ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts" + ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts $infra/nginx" scp.exe @ssh -P $port ` infrastructure/docker-compose.prod.yml ` "${user}@${hostName}:${infra}/docker-compose.prod.yml" scp.exe @ssh -P $port ` infrastructure/scripts/prod-remote-deploy.sh ` "${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh" - ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh" + scp.exe @ssh -P $port ` + infrastructure/scripts/render-nginx-ssl.sh ` + "${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh" + scp.exe @ssh -P $port ` + infrastructure/nginx/nginx.ssl.conf.template ` + "${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template" + ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh" - name: Login on Linux and deploy tag run: | diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index a14c67f..cfd05cd 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -4,7 +4,10 @@ # Production (nudentic.ir / git tags): .gitea/workflows/prod-tag-deploy.yml — this file is Windows staging only. # # Repository Variables (Settings → Actions → Variables): -# REGISTRY_HOST Docker registry host:port (no http/https). Windows Docker Desktop → host.docker.internal:3000 +# REGISTRY_HOST Docker registry host:port (no http/https). +# Windows Docker Desktop → host.docker.internal:3000 +# Do NOT use gitea.wixur.ir unless Windows nginx for that +# host has client_max_body_size 0 — Docker layer PUTs 413 otherwise. # REGISTRY_OWNER Gitea user or org that owns the packages # PUBLIC_BASE_URL URL users open in the browser, e.g. http://wixur.ir (no trailing slash, no :8088) # NEXT_PUBLIC_SENTRY_DSN GlitchTip frontend project DSN (https://…@errors.wixur.ir/…) diff --git a/backend/.env.example b/backend/.env.example index c59f8f7..2ed749d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -29,6 +29,10 @@ FRONTEND_URL=http://localhost:3001 # Set true when the app is served over HTTPS (required for Secure auth cookies) COOKIE_SECURE=false +# AdminJS panel at http://localhost:3000/admin (not under /api) +# ADMINJS_EMAIL=admin@dyolink.com +# ADMINJS_PASSWORD=admin123 + # OAuth (optional — uncomment when configured) # GOOGLE_CLIENT_ID=your-google-client-id # GOOGLE_CLIENT_SECRET=your-google-client-secret diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index bfe4a0f..1d7fd04 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -17,7 +17,10 @@ export class AdminModule { const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs'); const authenticate = async (email: string, password: string) => { - if (email === 'admin@dyolink.com' && password === 'admin123') { + const adminEmail = + process.env.ADMINJS_EMAIL?.trim() || 'admin@dyolink.com'; + const adminPassword = process.env.ADMINJS_PASSWORD || 'admin123'; + if (email === adminEmail && password === adminPassword) { return { email, role: 'admin' }; } return null; @@ -30,6 +33,19 @@ export class AdminModule { imports: [ConfigModule], inject: [PrismaService, ConfigService], useFactory: (prisma: PrismaService, config: ConfigService) => { + const cookieSecure = config.get('cookie.secure') === true; + const sessionSecret = + config.get('jwt.secret') || + config.get('JWT_SECRET') || + 'secret-key-change-this'; + if ( + process.env.NODE_ENV === 'production' && + !process.env.ADMINJS_PASSWORD + ) { + console.warn( + '⚠️ ADMINJS_PASSWORD is unset; AdminJS is using the local default. Set it in backend.env.', + ); + } return { adminJsOptions: { rootPath: '/admin', @@ -93,12 +109,17 @@ export class AdminModule { auth: { authenticate, cookieName: 'dyolink-admin', - cookiePassword: config.get('JWT_SECRET') || 'secret-key-change-this', + cookiePassword: sessionSecret, }, sessionOptions: { resave: false, saveUninitialized: false, - secret: config.get('JWT_SECRET') || 'secret-key-change-this', + secret: sessionSecret, + cookie: { + httpOnly: true, + sameSite: 'lax' as const, + secure: cookieSecure, + }, }, }; }, diff --git a/backend/src/main.ts b/backend/src/main.ts index 0ca6b5a..6d27162 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -30,6 +30,9 @@ async function bootstrap() { // Nest's built-in one would otherwise reject a voice recording at 100 kb. const app = await NestFactory.create(AppModule, { bodyParser: false }); + // Nginx terminates TLS; AdminJS sessions and Secure cookies need the real proto/host. + app.getHttpAdapter().getInstance().set('trust proxy', 1); + // Voice needs a larger JSON limit than everything else; see body-parsers.ts. app.use(createJsonBodyParser()); app.use(urlencoded({ extended: true })); diff --git a/infrastructure/DEPLOY.md b/infrastructure/DEPLOY.md index 40978f7..31d2c66 100644 --- a/infrastructure/DEPLOY.md +++ b/infrastructure/DEPLOY.md @@ -44,7 +44,7 @@ Then `sudo systemctl restart docker` (containers restart). - `REGISTRY_PREFIX=wixur.ir:3000/` (same owner as Gitea `REGISTRY_OWNER`) - `TAG=v1.0.1` (CI overrides per release) -3. **`secrets/backend.env`:** `FRONTEND_URL=https://nudentic.ir`, `COOKIE_SECURE=true` +3. **`secrets/backend.env`:** `FRONTEND_URL=https://nudentic.ir`, `COOKIE_SECURE=true`, plus `ADMINJS_EMAIL` / `ADMINJS_PASSWORD` for `https://nudentic.ir/admin` 4. **SSH** — user that can run `docker` (e.g. `dyolink` in the `docker` group). Put the matching **public** key in `~/.ssh/authorized_keys`. @@ -72,7 +72,7 @@ git push origin v1.0.1 Or Gitea → Actions → **Production — tag build, push, deploy** → Run → tag `v1.0.1`. -Check `https://nudentic.ir/api/health`. +Check `https://nudentic.ir/api/health`. AdminJS is `https://nudentic.ir/admin`. --- @@ -81,8 +81,9 @@ Check `https://nudentic.ir/api/health`. ``` Internet → Nginx (:80 / :443) - ├── / → frontend:3000 (Next.js) - └── /api → backend:3000 (NestJS) + ├── / → frontend:3000 (Next.js) + ├── /api → backend:3000 (NestJS API) + └── /admin → backend:3000 (AdminJS) └── postgres:5432 ``` @@ -496,6 +497,7 @@ docker compose -f docker-compose.prod.yml --env-file .env exec frontend \ - [ ] `.env`, `database.env`, `backend.env` configured (real passwords + JWT) - [ ] `./scripts/deploy-prod.sh` completed - [ ] `curl https://DOMAIN/api/health` returns `{"status":"ok",...}` +- [ ] `https://DOMAIN/admin` shows the AdminJS login (not the Next.js app) - [ ] App loads in browser --- diff --git a/infrastructure/STAGING-DEPLOY.md b/infrastructure/STAGING-DEPLOY.md index ab1fc1d..8dcecac 100644 --- a/infrastructure/STAGING-DEPLOY.md +++ b/infrastructure/STAGING-DEPLOY.md @@ -163,7 +163,7 @@ Rules: | Name | Example | Notes | |------|---------|--------| -| `REGISTRY_HOST` | `host.docker.internal:3000` | **Windows + Docker Desktop:** Docker runs in a Linux VM — `127.0.0.1` is the VM, not Gitea. Use `host.docker.internal:3000`. Gitea `ROOT_URL` should match this so registry login from CI works. Browsers and the Linux VPS use `http://wixur.ir:3000`. | +| `REGISTRY_HOST` | `host.docker.internal:3000` | **Windows + Docker Desktop:** Docker runs in a Linux VM — `127.0.0.1` is the VM, not Gitea. Use `host.docker.internal:3000`. Gitea `ROOT_URL` should match this so registry login from CI works. Browsers can use `http://wixur.ir:3000` or `https://gitea.wixur.ir`. **Do not** set this to `gitea.wixur.ir` unless that HTTPS proxy allows unlimited body size (see 413 below). | | `REGISTRY_OWNER` | `admin` | Gitea user/org owning packages | | `PUBLIC_BASE_URL` | `https://wixur.ir` | How **users** open staging (HTTPS on 443). No trailing slash. | | `DEPLOY_SECRETS_DIR` | `C:/dyolink/secrets` | Forward slashes OK on Windows | @@ -299,6 +299,7 @@ On the Windows host, from repo `infrastructure/`: | `no matching online runner with label` | Runner **offline** → start `act_runner.exe daemon`. Or wrong **runner level** → re-register with token from **Site Administration → Actions → Runners** or **repo → Settings → Actions → Runners** (not user profile). Confirm runner appears on **repo** Runners page as Online. | | Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` | | Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` | +| `413 Request Entity Too Large` on `docker push` to `https://gitea.wixur.ir/v2/…/blobs/uploads` | Nginx (or Cloudflare) in front of Gitea is rejecting the image layer. **Fix the proxy** (then `nginx -s reload`): in the `server { server_name gitea.wixur.ir; }` block set `client_max_body_size 0;` and `proxy_request_buffering off;` — snippet: [`nginx/windows-gitea.wixur.snippet.conf`](nginx/windows-gitea.wixur.snippet.conf). **Or skip the proxy:** set `REGISTRY_HOST=host.docker.internal:3000` (and Gitea `ROOT_URL`) so CI pushes to `:3000`. If the hostname is orange-clouded on Cloudflare, grey-cloud it (free plan caps uploads at 100MB). | | `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. | | `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). | | `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker | @@ -334,7 +335,8 @@ docker logs dyolink_frontend_staging --tail 50 | `infrastructure/database.staging.env.example` | Postgres secrets template | | `infrastructure/backend.staging.env.example` | API secrets template | | `infrastructure/nginx/http-only.conf` | HTTP reverse proxy **inside Docker** staging | -| `infrastructure/nginx/windows-edge-http.conf` | Windows **host** nginx on port 80 → 18088 | +| `infrastructure/nginx/windows-edge-http.conf` | Windows **host** nginx on port 80/443 → 18088 | +| `infrastructure/nginx/windows-gitea.wixur.snippet.conf` | Windows nginx vhost for `https://gitea.wixur.ir` (unlimited body — Docker push) | --- diff --git a/infrastructure/backend.prod.env.example b/infrastructure/backend.prod.env.example index 3b65956..9da4678 100644 --- a/infrastructure/backend.prod.env.example +++ b/infrastructure/backend.prod.env.example @@ -14,6 +14,11 @@ JWT_REFRESH_EXPIRES_IN=30d # Must match DOMAIN in .env — used for CORS, invite links, cookies FRONTEND_URL=https://nudentic.ir +# AdminJS at https://nudentic.ir/admin (nginx proxies /admin to the API). +# Change these — the code defaults are only for local development. +ADMINJS_EMAIL=admin@nudentic.ir +ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD + # Required for HTTPS — browsers reject Secure cookies over plain HTTP COOKIE_SECURE=true diff --git a/infrastructure/backend.staging.env.example b/infrastructure/backend.staging.env.example index 3005fb2..7fef5f3 100644 --- a/infrastructure/backend.staging.env.example +++ b/infrastructure/backend.staging.env.example @@ -12,6 +12,10 @@ JWT_REFRESH_EXPIRES_IN=30d # CORS, cookies, and invite links — must match how users open the app FRONTEND_URL=https://wixur.ir +# AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API). +ADMINJS_EMAIL=admin@wixur.ir +ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD + # TLS is terminated on Windows nginx :443 — cookies must be Secure COOKIE_SECURE=true diff --git a/infrastructure/nginx/http-only.conf b/infrastructure/nginx/http-only.conf index 9594e0b..122755e 100644 --- a/infrastructure/nginx/http-only.conf +++ b/infrastructure/nginx/http-only.conf @@ -43,6 +43,22 @@ server { proxy_connect_timeout 300; } + # AdminJS (Nest, not under /api) + location /admin { + set $backend_upstream http://backend:3000; + proxy_pass $backend_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + } + location /health { access_log off; return 200 "healthy\n"; diff --git a/infrastructure/nginx/http-only.dev.conf b/infrastructure/nginx/http-only.dev.conf index 6c6197d..36ca8b6 100644 --- a/infrastructure/nginx/http-only.dev.conf +++ b/infrastructure/nginx/http-only.dev.conf @@ -46,6 +46,21 @@ server { proxy_connect_timeout 300; } + # AdminJS (Nest, not under /api) + location /admin { + proxy_pass http://dyolink_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + } + location /health { access_log off; return 200 "healthy\n"; diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index 52a66d9..595091d 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -86,6 +86,21 @@ server { proxy_read_timeout 300; proxy_connect_timeout 300; } + + # AdminJS (Nest, not under /api) + location /admin { + proxy_pass http://dyolink_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + } # Health check endpoint (no logging) location /health { diff --git a/infrastructure/nginx/nginx.ssl.conf.template b/infrastructure/nginx/nginx.ssl.conf.template index 042eff2..5917064 100644 --- a/infrastructure/nginx/nginx.ssl.conf.template +++ b/infrastructure/nginx/nginx.ssl.conf.template @@ -83,6 +83,21 @@ server { proxy_connect_timeout 300; } + # AdminJS (Nest, not under /api) — https://nudentic.ir/admin + location /admin { + proxy_pass http://dyolink_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + } + location /health { access_log off; return 200 "healthy\n"; diff --git a/infrastructure/nginx/windows-edge-http.conf b/infrastructure/nginx/windows-edge-http.conf index 36044a1..7c5f23b 100644 --- a/infrastructure/nginx/windows-edge-http.conf +++ b/infrastructure/nginx/windows-edge-http.conf @@ -26,7 +26,9 @@ http { sendfile on; keepalive_timeout 65; - client_max_body_size 50M; + # Unlimited at http{} so a gitea.wixur.ir vhost can inherit it (Docker layers + # 413 with 50M). App server blocks below cap uploads at 50M. + client_max_body_size 0; # Shared proxy to Docker staging map $http_upgrade $connection_upgrade { @@ -39,6 +41,8 @@ http { listen [::]:80 default_server; server_name wixur.ir www.wixur.ir localhost 127.0.0.1; + client_max_body_size 50M; + location / { proxy_pass http://127.0.0.1:18088; proxy_http_version 1.1; @@ -62,6 +66,8 @@ http { ssl_certificate_key ssl/wixur-key.pem; ssl_protocols TLSv1.2 TLSv1.3; + client_max_body_size 50M; + location / { proxy_pass http://127.0.0.1:18088; proxy_http_version 1.1; @@ -75,4 +81,8 @@ http { proxy_connect_timeout 300; } } + + # Gitea + container registry (https://gitea.wixur.ir). Optional include — + # copy windows-gitea.wixur.snippet.conf next to this file and uncomment: + # include windows-gitea.wixur.snippet.conf; } diff --git a/infrastructure/nginx/windows-gitea.wixur.snippet.conf b/infrastructure/nginx/windows-gitea.wixur.snippet.conf new file mode 100644 index 0000000..8de5631 --- /dev/null +++ b/infrastructure/nginx/windows-gitea.wixur.snippet.conf @@ -0,0 +1,58 @@ +# Fastest fix if you already have a gitea.wixur.ir server { } block: add only +# client_max_body_size 0; +# proxy_request_buffering off; +# inside that server (or its location /), then nginx -t && nginx -s reload. +# Do not add a second server_name gitea.wixur.ir — duplicate listen/ssl will fail. +# +# Full vhost (only if that host is not already in nginx.conf): paste inside http { } +# of C:\tools\nginx-1.29.5\conf\nginx.conf. Needs the $connection_upgrade map from +# windows-edge-http.conf. +# +# Certs: keep the ssl_certificate paths you already use for gitea.wixur.ir +# (the names below are placeholders). +# +# After save: nginx -t then nginx -s reload + +server { + listen 80; + listen [::]:80; + server_name gitea.wixur.ir; + + location /.well-known/acme-challenge/ { + root html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name gitea.wixur.ir; + + ssl_certificate ssl/gitea-chain.pem; + ssl_certificate_key ssl/gitea-key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # 0 = unlimited (Docker registry blob PUT) + client_max_body_size 0; + client_body_timeout 600s; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600; + proxy_connect_timeout 60; + proxy_send_timeout 600; + } +} diff --git a/infrastructure/scripts/prod-remote-deploy.sh b/infrastructure/scripts/prod-remote-deploy.sh index 6a07948..f81589b 100755 --- a/infrastructure/scripts/prod-remote-deploy.sh +++ b/infrastructure/scripts/prod-remote-deploy.sh @@ -45,7 +45,16 @@ pull_one() { echo "Pulling backend/frontend :$TAG from Gitea (REGISTRY_PREFIX in .env)" pull_one backend pull_one frontend + +if [ -f nginx/nginx.ssl.conf.template ]; then + echo "Rendering nginx SSL config (includes /admin → backend)" + chmod +x scripts/render-nginx-ssl.sh + ./scripts/render-nginx-ssl.sh +fi + +docker compose -f docker-compose.prod.yml --env-file .env up -d --force-recreate nginx docker compose -f docker-compose.prod.yml --env-file .env up -d echo "=== Status ===" docker compose -f docker-compose.prod.yml --env-file .env ps echo "Health: https://nudentic.ir/api/health" +echo "AdminJS: https://nudentic.ir/admin"