diff --git a/.gitea/workflows/prod-tag-deploy.yml b/.gitea/workflows/prod-tag-deploy.yml index 7828559..f94e37c 100644 --- a/.gitea/workflows/prod-tag-deploy.yml +++ b/.gitea/workflows/prod-tag-deploy.yml @@ -191,14 +191,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/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/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/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"