Expose AdminJS on nudentic.ir: nginx only proxied /api, so /admin never reached Nest.

This commit is contained in:
2026-09-01 11:49:14 +03:30
parent dc71c73ced
commit 6465af11ee
12 changed files with 124 additions and 9 deletions

View File

@@ -191,14 +191,20 @@ jobs:
$infra = '${{ vars.PROD_INFRA_DIR }}'.Trim() $infra = '${{ vars.PROD_INFRA_DIR }}'.Trim()
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' } if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new') $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 ` scp.exe @ssh -P $port `
infrastructure/docker-compose.prod.yml ` infrastructure/docker-compose.prod.yml `
"${user}@${hostName}:${infra}/docker-compose.prod.yml" "${user}@${hostName}:${infra}/docker-compose.prod.yml"
scp.exe @ssh -P $port ` scp.exe @ssh -P $port `
infrastructure/scripts/prod-remote-deploy.sh ` infrastructure/scripts/prod-remote-deploy.sh `
"${user}@${hostName}:${infra}/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 - name: Login on Linux and deploy tag
run: | run: |

View File

@@ -29,6 +29,10 @@ FRONTEND_URL=http://localhost:3001
# Set true when the app is served over HTTPS (required for Secure auth cookies) # Set true when the app is served over HTTPS (required for Secure auth cookies)
COOKIE_SECURE=false 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) # OAuth (optional — uncomment when configured)
# GOOGLE_CLIENT_ID=your-google-client-id # GOOGLE_CLIENT_ID=your-google-client-id
# GOOGLE_CLIENT_SECRET=your-google-client-secret # GOOGLE_CLIENT_SECRET=your-google-client-secret

View File

@@ -17,7 +17,10 @@ export class AdminModule {
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs'); const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
const authenticate = async (email: string, password: string) => { 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 { email, role: 'admin' };
} }
return null; return null;
@@ -30,6 +33,19 @@ export class AdminModule {
imports: [ConfigModule], imports: [ConfigModule],
inject: [PrismaService, ConfigService], inject: [PrismaService, ConfigService],
useFactory: (prisma: PrismaService, config: ConfigService) => { useFactory: (prisma: PrismaService, config: ConfigService) => {
const cookieSecure = config.get<boolean>('cookie.secure') === true;
const sessionSecret =
config.get<string>('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 { return {
adminJsOptions: { adminJsOptions: {
rootPath: '/admin', rootPath: '/admin',
@@ -93,12 +109,17 @@ export class AdminModule {
auth: { auth: {
authenticate, authenticate,
cookieName: 'dyolink-admin', cookieName: 'dyolink-admin',
cookiePassword: config.get('JWT_SECRET') || 'secret-key-change-this', cookiePassword: sessionSecret,
}, },
sessionOptions: { sessionOptions: {
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
secret: config.get('JWT_SECRET') || 'secret-key-change-this', secret: sessionSecret,
cookie: {
httpOnly: true,
sameSite: 'lax' as const,
secure: cookieSecure,
},
}, },
}; };
}, },

View File

@@ -30,6 +30,9 @@ async function bootstrap() {
// Nest's built-in one would otherwise reject a voice recording at 100 kb. // Nest's built-in one would otherwise reject a voice recording at 100 kb.
const app = await NestFactory.create(AppModule, { bodyParser: false }); 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. // Voice needs a larger JSON limit than everything else; see body-parsers.ts.
app.use(createJsonBodyParser()); app.use(createJsonBodyParser());
app.use(urlencoded({ extended: true })); app.use(urlencoded({ extended: true }));

View File

@@ -44,7 +44,7 @@ Then `sudo systemctl restart docker` (containers restart).
- `REGISTRY_PREFIX=wixur.ir:3000/<gitea-owner>` (same owner as Gitea `REGISTRY_OWNER`) - `REGISTRY_PREFIX=wixur.ir:3000/<gitea-owner>` (same owner as Gitea `REGISTRY_OWNER`)
- `TAG=v1.0.1` (CI overrides per release) - `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`. 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`. 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) Internet → Nginx (:80 / :443)
├── / → frontend:3000 (Next.js) ├── / → frontend:3000 (Next.js)
── /api → backend:3000 (NestJS) ── /api → backend:3000 (NestJS API)
└── /admin → backend:3000 (AdminJS)
└── postgres:5432 └── 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) - [ ] `.env`, `database.env`, `backend.env` configured (real passwords + JWT)
- [ ] `./scripts/deploy-prod.sh` completed - [ ] `./scripts/deploy-prod.sh` completed
- [ ] `curl https://DOMAIN/api/health` returns `{"status":"ok",...}` - [ ] `curl https://DOMAIN/api/health` returns `{"status":"ok",...}`
- [ ] `https://DOMAIN/admin` shows the AdminJS login (not the Next.js app)
- [ ] App loads in browser - [ ] App loads in browser
--- ---

View File

@@ -14,6 +14,11 @@ JWT_REFRESH_EXPIRES_IN=30d
# Must match DOMAIN in .env — used for CORS, invite links, cookies # Must match DOMAIN in .env — used for CORS, invite links, cookies
FRONTEND_URL=https://nudentic.ir 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 # Required for HTTPS — browsers reject Secure cookies over plain HTTP
COOKIE_SECURE=true COOKIE_SECURE=true

View File

@@ -12,6 +12,10 @@ JWT_REFRESH_EXPIRES_IN=30d
# CORS, cookies, and invite links — must match how users open the app # CORS, cookies, and invite links — must match how users open the app
FRONTEND_URL=https://wixur.ir 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 # TLS is terminated on Windows nginx :443 — cookies must be Secure
COOKIE_SECURE=true COOKIE_SECURE=true

View File

@@ -43,6 +43,22 @@ server {
proxy_connect_timeout 300; 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 { location /health {
access_log off; access_log off;
return 200 "healthy\n"; return 200 "healthy\n";

View File

@@ -46,6 +46,21 @@ server {
proxy_connect_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;
}
location /health { location /health {
access_log off; access_log off;
return 200 "healthy\n"; return 200 "healthy\n";

View File

@@ -86,6 +86,21 @@ server {
proxy_read_timeout 300; proxy_read_timeout 300;
proxy_connect_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) # Health check endpoint (no logging)
location /health { location /health {

View File

@@ -83,6 +83,21 @@ server {
proxy_connect_timeout 300; 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 { location /health {
access_log off; access_log off;
return 200 "healthy\n"; return 200 "healthy\n";

View File

@@ -45,7 +45,16 @@ pull_one() {
echo "Pulling backend/frontend :$TAG from Gitea (REGISTRY_PREFIX in .env)" echo "Pulling backend/frontend :$TAG from Gitea (REGISTRY_PREFIX in .env)"
pull_one backend pull_one backend
pull_one frontend 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 docker compose -f docker-compose.prod.yml --env-file .env up -d
echo "=== Status ===" echo "=== Status ==="
docker compose -f docker-compose.prod.yml --env-file .env ps docker compose -f docker-compose.prod.yml --env-file .env ps
echo "Health: https://nudentic.ir/api/health" echo "Health: https://nudentic.ir/api/health"
echo "AdminJS: https://nudentic.ir/admin"