Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60be656cf1 | |||
| 6465af11ee | |||
| dc71c73ced | |||
| 19364ad5c2 | |||
| 50eda34e8e |
@@ -40,7 +40,7 @@ QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `
|
|||||||
|
|
||||||
## API errors
|
## API errors
|
||||||
|
|
||||||
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
|
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Unexpected 500s / client crashes: GlitchTip via Sentry SDKs (`SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN`). Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
|
||||||
|
|
||||||
## Notifications (inbox + live tabs)
|
## Notifications (inbox + live tabs)
|
||||||
|
|
||||||
|
|||||||
20
.cursor/rules/error-tracking.mdc
Normal file
20
.cursor/rules/error-tracking.mdc
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
description: GlitchTip error tracking via Sentry SDKs — 500s only, no PHI
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Error tracking (GlitchTip)
|
||||||
|
|
||||||
|
Self-hosted GlitchTip at `https://errors.wixur.ir`. Apps use official Sentry SDKs (`@sentry/nestjs`, `@sentry/nextjs`) pointed at that DSN — not sentry.io.
|
||||||
|
|
||||||
|
## What to report
|
||||||
|
|
||||||
|
- Backend: HTTP **500+** in `HttpExceptionFilter` only. Do **not** send `AppException` 4xx.
|
||||||
|
- Frontend: render crashes (`error.tsx` / `global-error.tsx`) and axios **5xx / network** failures only.
|
||||||
|
- No session replay. Scrub cookies, JWT, request bodies, emails.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
- Backend DSN: `SENTRY_DSN` in `backend.env` / `backend.staging.env` (runtime).
|
||||||
|
- Frontend DSN: `NEXT_PUBLIC_SENTRY_DSN` baked at Docker build (Gitea var `NEXT_PUBLIC_SENTRY_DSN`).
|
||||||
|
- Empty DSN = tracking off (local default).
|
||||||
@@ -8,9 +8,11 @@
|
|||||||
#
|
#
|
||||||
# Variables:
|
# Variables:
|
||||||
# REGISTRY_HOST Windows Docker push host, e.g. host.docker.internal:3000
|
# 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
|
# REGISTRY_OWNER Gitea user/org for packages
|
||||||
# CLONE_HOST git clone, e.g. 127.0.0.1:3000
|
# CLONE_HOST git clone, e.g. 127.0.0.1:3000
|
||||||
# PROD_PUBLIC_BASE_URL https://nudentic.ir (no trailing slash)
|
# PROD_PUBLIC_BASE_URL https://nudentic.ir (no trailing slash)
|
||||||
|
# NEXT_PUBLIC_SENTRY_DSN GlitchTip frontend project DSN
|
||||||
# PROD_REGISTRY_HOST Linux pull host, e.g. wixur.ir:3000 (HTTP; insecure-registries on the VPS)
|
# PROD_REGISTRY_HOST Linux pull host, e.g. wixur.ir:3000 (HTTP; insecure-registries on the VPS)
|
||||||
# PROD_INFRA_DIR /opt/dyolink/infrastructure
|
# PROD_INFRA_DIR /opt/dyolink/infrastructure
|
||||||
#
|
#
|
||||||
@@ -112,6 +114,7 @@ jobs:
|
|||||||
- name: Build and push frontend (nudentic.ir baked in)
|
- name: Build and push frontend (nudentic.ir baked in)
|
||||||
env:
|
env:
|
||||||
PROD_PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
PROD_PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||||
@@ -122,6 +125,8 @@ jobs:
|
|||||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
||||||
./frontend
|
./frontend
|
||||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
||||||
@@ -187,14 +192,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: |
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
# Production (nudentic.ir / git tags): .gitea/workflows/prod-tag-deploy.yml — this file is Windows staging only.
|
# Production (nudentic.ir / git tags): .gitea/workflows/prod-tag-deploy.yml — this file is Windows staging only.
|
||||||
#
|
#
|
||||||
# Repository Variables (Settings → Actions → Variables):
|
# 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
|
# 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)
|
# 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/…)
|
||||||
# DEPLOY_SECRETS_DIR absolute path on runner, e.g. C:/dyolink/secrets
|
# DEPLOY_SECRETS_DIR absolute path on runner, e.g. C:/dyolink/secrets
|
||||||
#
|
#
|
||||||
# Optional:
|
# Optional:
|
||||||
@@ -103,6 +107,7 @@ jobs:
|
|||||||
- name: Build and push frontend
|
- name: Build and push frontend
|
||||||
env:
|
env:
|
||||||
PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||||
@@ -111,6 +116,8 @@ jobs:
|
|||||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
|
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
|
||||||
./frontend
|
./frontend
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ backend/src/
|
|||||||
prisma/ → schema, migrations, seed
|
prisma/ → schema, migrations, seed
|
||||||
```
|
```
|
||||||
|
|
||||||
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never throw raw strings for user-facing failures.
|
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures.
|
||||||
|
|
||||||
## Git & commits
|
## Git & commits
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -72,3 +76,7 @@ OPENROUTER_API_KEY=
|
|||||||
# plus processing takes ten seconds at minimum — so it is purely an abuse guard.
|
# plus processing takes ten seconds at minimum — so it is purely an abuse guard.
|
||||||
# VOICE_THROTTLE_TTL=60
|
# VOICE_THROTTLE_TTL=60
|
||||||
# VOICE_THROTTLE_LIMIT=6
|
# VOICE_THROTTLE_LIMIT=6
|
||||||
|
|
||||||
|
# GlitchTip (optional). Leave empty for local unless you want to send events.
|
||||||
|
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
|
||||||
|
# SENTRY_ENVIRONMENT=development
|
||||||
|
|||||||
287
backend/package-lock.json
generated
287
backend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.28",
|
"@nestjs/websockets": "^11.1.28",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@sentry/nestjs": "^10.72.0",
|
||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -4649,6 +4650,119 @@
|
|||||||
"npm": ">=5.10.0"
|
"npm": ">=5.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@opentelemetry/api": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/api-logs": {
|
||||||
|
"version": "0.220.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz",
|
||||||
|
"integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/api": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/core": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.19.0 || >=20.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/instrumentation": {
|
||||||
|
"version": "0.220.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz",
|
||||||
|
"integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/api-logs": "0.220.0",
|
||||||
|
"import-in-the-middle": "^3.0.0",
|
||||||
|
"require-in-the-middle": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.19.0 || >=20.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/resources": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/core": "2.10.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.19.0 || >=20.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/sdk-trace": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/core": "2.10.0",
|
||||||
|
"@opentelemetry/resources": "2.10.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.19.0 || >=20.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/core": "2.10.0",
|
||||||
|
"@opentelemetry/resources": "2.10.0",
|
||||||
|
"@opentelemetry/sdk-trace": "2.10.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.19.0 || >=20.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/semantic-conventions": {
|
||||||
|
"version": "1.43.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
|
||||||
|
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@paralleldrive/cuid2": {
|
"node_modules/@paralleldrive/cuid2": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||||
@@ -5333,6 +5447,138 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@sentry/conventions": {
|
||||||
|
"version": "0.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
||||||
|
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/core": {
|
||||||
|
"version": "10.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.72.0.tgz",
|
||||||
|
"integrity": "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/conventions": "^0.16.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/nestjs": {
|
||||||
|
"version": "10.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/nestjs/-/nestjs-10.72.0.tgz",
|
||||||
|
"integrity": "sha512-+1wdX+fkv8zIZ+lI66Se1DKwPXoa6edxoHuAgWDwcqv3bKF8R75NqzXj+4U7FcgBI05i6HFiZJuyvx9BCECy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/instrumentation": "^0.220.0",
|
||||||
|
"@sentry/conventions": "^0.16.0",
|
||||||
|
"@sentry/core": "10.72.0",
|
||||||
|
"@sentry/node": "10.72.0",
|
||||||
|
"@sentry/server-utils": "10.72.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/node": {
|
||||||
|
"version": "10.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.72.0.tgz",
|
||||||
|
"integrity": "sha512-eQHQFxSX26MhG/nB+tAY4QRslnPvJse7pww/hd3zXAqNULRa5lFtjSLALcHx/KUVDCZdTPdUVZEj4nGidcHrow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/instrumentation": "^0.220.0",
|
||||||
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
||||||
|
"@sentry/conventions": "^0.16.0",
|
||||||
|
"@sentry/core": "10.72.0",
|
||||||
|
"@sentry/node-core": "10.72.0",
|
||||||
|
"@sentry/opentelemetry": "10.72.0",
|
||||||
|
"@sentry/server-utils": "10.72.0",
|
||||||
|
"import-in-the-middle": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/node-core": {
|
||||||
|
"version": "10.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.72.0.tgz",
|
||||||
|
"integrity": "sha512-xYuYWmWEWnN8h3YHa7mds212ANFgrxfrbmLvVg0p0wf9m6MFjLowOTmjaiK0XW20sM/veSelicre650bx8UFtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/conventions": "^0.16.0",
|
||||||
|
"@sentry/core": "10.72.0",
|
||||||
|
"@sentry/opentelemetry": "10.72.0",
|
||||||
|
"import-in-the-middle": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1",
|
||||||
|
"@opentelemetry/instrumentation": ">=0.57.1 <1",
|
||||||
|
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/instrumentation": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/sdk-trace-base": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/opentelemetry": {
|
||||||
|
"version": "10.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.72.0.tgz",
|
||||||
|
"integrity": "sha512-ZVbAM1rGU/awN7cH/jvC87WpQw0NOJx5id6dKnnefStXpP/kUnZXYhtX9gfBnofYvJWa5I5VUSeuKCLUcKL75w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/conventions": "^0.16.0",
|
||||||
|
"@sentry/core": "10.72.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||||
|
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/server-utils": {
|
||||||
|
"version": "10.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.72.0.tgz",
|
||||||
|
"integrity": "sha512-CWpHMYW81RDBSVBdnxulGUvvBhkBb/OpSr72ubSe1UkKTcgT0NDMCWxE3dLFXqSxzT4DaF5UlUVv9ii5Sse53w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/conventions": "^0.16.0",
|
||||||
|
"@sentry/core": "10.72.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.49",
|
"version": "0.34.49",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||||
@@ -8352,7 +8598,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||||
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/class-transformer": {
|
"node_modules/class-transformer": {
|
||||||
@@ -9222,10 +9467,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
@@ -10649,6 +10893,20 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/import-in-the-middle": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"cjs-module-lexer": "^2.2.0",
|
||||||
|
"es-module-lexer": "^2.2.0",
|
||||||
|
"module-details-from-path": "^1.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-local": {
|
"node_modules/import-local": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||||
@@ -12517,6 +12775,12 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/module-details-from-path": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -14205,6 +14469,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-in-the-middle": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.5",
|
||||||
|
"module-details-from-path": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.28",
|
"@nestjs/websockets": "^11.1.28",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@sentry/nestjs": "^10.72.0",
|
||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { SentryModule } from '@sentry/nestjs/setup';
|
||||||
import configurations from './configs/configurations';
|
import configurations from './configs/configurations';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
@@ -25,6 +26,7 @@ import { VoiceModule } from './modules/voice/voice.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SentryModule.forRoot(),
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [configurations],
|
load: [configurations],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import * as Sentry from '@sentry/nestjs';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { AppException, type AppErrorResponse } from './app.exception';
|
import { AppException, type AppErrorResponse } from './app.exception';
|
||||||
import { ErrorCode, type ErrorCodeValue } from './error-codes';
|
import { ErrorCode, type ErrorCodeValue } from './error-codes';
|
||||||
@@ -42,6 +43,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
exception instanceof Error ? exception.stack : String(exception),
|
exception instanceof Error ? exception.stack : String(exception),
|
||||||
);
|
);
|
||||||
|
if (Sentry.getClient()) {
|
||||||
|
Sentry.captureException(exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(statusCode).json(body);
|
response.status(statusCode).json(body);
|
||||||
|
|||||||
36
backend/src/instrument.ts
Normal file
36
backend/src/instrument.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import * as Sentry from '@sentry/nestjs';
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
const dsn = process.env.SENTRY_DSN?.trim();
|
||||||
|
|
||||||
|
if (dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment:
|
||||||
|
process.env.SENTRY_ENVIRONMENT?.trim() ||
|
||||||
|
process.env.NODE_ENV ||
|
||||||
|
'development',
|
||||||
|
release: process.env.SENTRY_RELEASE?.trim() || undefined,
|
||||||
|
sendDefaultPii: false,
|
||||||
|
tracesSampleRate: 0,
|
||||||
|
beforeSend(event) {
|
||||||
|
if (event.request) {
|
||||||
|
delete event.request.cookies;
|
||||||
|
delete event.request.data;
|
||||||
|
if (event.request.headers) {
|
||||||
|
delete event.request.headers.cookie;
|
||||||
|
delete event.request.headers.authorization;
|
||||||
|
delete event.request.headers.Authorization;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.user) {
|
||||||
|
delete event.user.email;
|
||||||
|
delete event.user.ip_address;
|
||||||
|
delete event.user.username;
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// backend/src/main.ts
|
// backend/src/main.ts
|
||||||
|
import './instrument';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { urlencoded } from 'express';
|
import { urlencoded } from 'express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
@@ -29,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 }));
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
|||||||
NEXT_PUBLIC_APP_NAME=DyoLink
|
NEXT_PUBLIC_APP_NAME=DyoLink
|
||||||
# URL where users open the frontend (used for metadata, images, etc.)
|
# URL where users open the frontend (used for metadata, images, etc.)
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# GlitchTip frontend project DSN (optional locally). Baked into the Docker image in CI.
|
||||||
|
# NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/2
|
||||||
|
# NEXT_PUBLIC_SENTRY_ENVIRONMENT=development
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ COPY . .
|
|||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ARG NEXT_PUBLIC_APP_URL
|
ARG NEXT_PUBLIC_APP_URL
|
||||||
ARG NEXT_PUBLIC_APP_NAME
|
ARG NEXT_PUBLIC_APP_NAME
|
||||||
|
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||||
|
ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||||
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
|
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
|
||||||
|
ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
|
||||||
|
ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT}
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
"redirecting": "Redirecting…",
|
"redirecting": "Redirecting…",
|
||||||
"readOnlyAccess": "Read-only access for this organization.",
|
"readOnlyAccess": "Read-only access for this organization.",
|
||||||
"errorGeneric": "Something went wrong",
|
"errorGeneric": "Something went wrong",
|
||||||
|
"pageErrorTitle": "Something went wrong",
|
||||||
|
"pageErrorBody": "This page failed to load. You can try again.",
|
||||||
|
"tryAgain": "Try again",
|
||||||
"loadingEllipsis": "Loading...",
|
"loadingEllipsis": "Loading...",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
"redirecting": "در حال انتقال...",
|
"redirecting": "در حال انتقال...",
|
||||||
"readOnlyAccess": "دسترسی فقط خواندنی برای این سازمان.",
|
"readOnlyAccess": "دسترسی فقط خواندنی برای این سازمان.",
|
||||||
"errorGeneric": "خطایی رخ داده است",
|
"errorGeneric": "خطایی رخ داده است",
|
||||||
|
"pageErrorTitle": "خطایی رخ داده است",
|
||||||
|
"pageErrorBody": "این صفحه بارگذاری نشد. میتوانید دوباره تلاش کنید.",
|
||||||
|
"tryAgain": "تلاش دوباره",
|
||||||
"loadingEllipsis": "در حال بارگذاری...",
|
"loadingEllipsis": "در حال بارگذاری...",
|
||||||
"search": "جستجو",
|
"search": "جستجو",
|
||||||
"action": "عملیات",
|
"action": "عملیات",
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
"redirecting": "Bezig met doorsturen...",
|
"redirecting": "Bezig met doorsturen...",
|
||||||
"readOnlyAccess": "Alleen-lezen toegang voor deze organisatie.",
|
"readOnlyAccess": "Alleen-lezen toegang voor deze organisatie.",
|
||||||
"errorGeneric": "Er is iets misgegaan",
|
"errorGeneric": "Er is iets misgegaan",
|
||||||
|
"pageErrorTitle": "Er is iets misgegaan",
|
||||||
|
"pageErrorBody": "Deze pagina kon niet worden geladen. U kunt het opnieuw proberen.",
|
||||||
|
"tryAgain": "Opnieuw proberen",
|
||||||
"loadingEllipsis": "Laden...",
|
"loadingEllipsis": "Laden...",
|
||||||
"search": "Zoeken",
|
"search": "Zoeken",
|
||||||
"action": "Actie",
|
"action": "Actie",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||||
@@ -35,9 +36,15 @@ const nextConfig: NextConfig = {
|
|||||||
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
||||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
|
||||||
},
|
},
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
compress: true,
|
compress: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withSentryConfig(withNextIntl(nextConfig), {
|
||||||
|
silent: true,
|
||||||
|
sourcemaps: { disable: true },
|
||||||
|
disableLogger: true,
|
||||||
|
});
|
||||||
|
|||||||
1991
frontend/package-lock.json
generated
1991
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@sentry/nextjs": "^10.72.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
|||||||
30
frontend/src/app/[locale]/error.tsx
Normal file
30
frontend/src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
|
||||||
|
export default function LocaleError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('common');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">{t('pageErrorTitle')}</h1>
|
||||||
|
<p className="max-w-md text-sm text-text-secondary">{t('pageErrorBody')}</p>
|
||||||
|
<Button type="button" onClick={() => reset()}>
|
||||||
|
{t('tryAgain')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/app/global-error.tsx
Normal file
44
frontend/src/app/global-error.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: '1.125rem', fontWeight: 600 }}>Something went wrong</h1>
|
||||||
|
<p style={{ maxWidth: '28rem', fontSize: '0.875rem' }}>
|
||||||
|
The page failed to load. You can try again.
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => reset()}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
|
||||||
interface TodayWidgetErrorBoundaryProps {
|
interface TodayWidgetErrorBoundaryProps {
|
||||||
@@ -23,6 +24,7 @@ export class TodayWidgetErrorBoundary extends Component<
|
|||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
console.error('Today widget render error:', error, info);
|
console.error('Today widget render error:', error, info);
|
||||||
|
Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
15
frontend/src/instrumentation-client.ts
Normal file
15
frontend/src/instrumentation-client.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import { sentrySharedOptions } from '@/lib/error-tracking/sentrySharedOptions';
|
||||||
|
|
||||||
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN?.trim();
|
||||||
|
|
||||||
|
if (dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment:
|
||||||
|
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT?.trim() ||
|
||||||
|
process.env.NODE_ENV ||
|
||||||
|
'development',
|
||||||
|
...sentrySharedOptions(),
|
||||||
|
});
|
||||||
|
}
|
||||||
9
frontend/src/instrumentation.ts
Normal file
9
frontend/src/instrumentation.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
export async function register(): Promise<void> {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
await import('./lib/error-tracking/sentry.server.config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
import type { ApiError } from '@/types/api';
|
import type { ApiError } from '@/types/api';
|
||||||
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||||
|
import { reportUnexpectedApiFailure } from '@/lib/error-tracking/reportUnexpectedApiFailure';
|
||||||
|
|
||||||
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||||
_retry?: boolean;
|
_retry?: boolean;
|
||||||
@@ -46,6 +47,7 @@ function shouldSkipRefreshRetry(url: string | undefined): boolean {
|
|||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
|
reportUnexpectedApiFailure(error);
|
||||||
const originalRequest = error.config as CustomAxiosRequestConfig;
|
const originalRequest = error.config as CustomAxiosRequestConfig;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
/** Report network failures and HTTP 5xx only — not coded 4xx AppExceptions. */
|
||||||
|
export function reportUnexpectedApiFailure(error: AxiosError): void {
|
||||||
|
const status = error.response?.status;
|
||||||
|
if (status !== undefined && status < 500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.captureException(error, {
|
||||||
|
tags: {
|
||||||
|
api_status: status ? String(status) : 'network',
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
url: error.config?.url,
|
||||||
|
method: error.config?.method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
15
frontend/src/lib/error-tracking/sentry.server.config.ts
Normal file
15
frontend/src/lib/error-tracking/sentry.server.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import { sentrySharedOptions } from '@/lib/error-tracking/sentrySharedOptions';
|
||||||
|
|
||||||
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN?.trim();
|
||||||
|
|
||||||
|
if (dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment:
|
||||||
|
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT?.trim() ||
|
||||||
|
process.env.NODE_ENV ||
|
||||||
|
'development',
|
||||||
|
...sentrySharedOptions(),
|
||||||
|
});
|
||||||
|
}
|
||||||
28
frontend/src/lib/error-tracking/sentrySharedOptions.ts
Normal file
28
frontend/src/lib/error-tracking/sentrySharedOptions.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ErrorEvent } from '@sentry/core';
|
||||||
|
|
||||||
|
/** Shared Sentry/GlitchTip options — no session replay, no PII in payloads. */
|
||||||
|
export function sentrySharedOptions() {
|
||||||
|
return {
|
||||||
|
sendDefaultPii: false as const,
|
||||||
|
tracesSampleRate: 0,
|
||||||
|
replaysSessionSampleRate: 0,
|
||||||
|
replaysOnErrorSampleRate: 0,
|
||||||
|
beforeSend(event: ErrorEvent): ErrorEvent {
|
||||||
|
if (event.request) {
|
||||||
|
delete event.request.cookies;
|
||||||
|
delete event.request.data;
|
||||||
|
if (event.request.headers) {
|
||||||
|
delete event.request.headers.cookie;
|
||||||
|
delete event.request.headers.authorization;
|
||||||
|
delete event.request.headers.Authorization;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.user) {
|
||||||
|
delete event.user.email;
|
||||||
|
delete event.user.ip_address;
|
||||||
|
delete event.user.username;
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -439,6 +440,19 @@ docker volume rm dyolink_postgres_data_prod
|
|||||||
|
|
||||||
Add `"insecure-registries": ["wixur.ir:3000"]` to `/etc/docker/daemon.json` and restart Docker.
|
Add `"insecure-registries": ["wixur.ir:3000"]` to `/etc/docker/daemon.json` and restart Docker.
|
||||||
|
|
||||||
|
### `connection reset by peer` while pulling `:v*` from Gitea
|
||||||
|
|
||||||
|
`docker login` succeeded, then `compose pull` failed on a frontend/backend manifest or layer from `wixur.ir:3000`. Auth is fine — the Windows Gitea registry dropped the TCP connection (common right after a large push, or when both images pull in parallel).
|
||||||
|
|
||||||
|
Images are already in the registry. Re-run only the **deploy** job, or on the VPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/dyolink/infrastructure
|
||||||
|
./scripts/prod-remote-deploy.sh v1.0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
`prod-remote-deploy.sh` pulls backend then frontend with retries. If every attempt RSTs, check Gitea is up and `insecure-registries` includes `wixur.ir:3000`.
|
||||||
|
|
||||||
### View logs
|
### View logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -483,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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ Rules:
|
|||||||
|
|
||||||
| Name | Example | Notes |
|
| 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 |
|
| `REGISTRY_OWNER` | `admin` | Gitea user/org owning packages |
|
||||||
| `PUBLIC_BASE_URL` | `https://wixur.ir` | How **users** open staging (HTTPS on 443). No trailing slash. |
|
| `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 |
|
| `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. |
|
| `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` |
|
| 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` |
|
| 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` 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). |
|
| `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 |
|
| `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/database.staging.env.example` | Postgres secrets template |
|
||||||
| `infrastructure/backend.staging.env.example` | API secrets template |
|
| `infrastructure/backend.staging.env.example` | API secrets template |
|
||||||
| `infrastructure/nginx/http-only.conf` | HTTP reverse proxy **inside Docker** staging |
|
| `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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,18 @@ 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
|
||||||
|
|
||||||
# SMS (sms.ir)
|
# SMS (sms.ir)
|
||||||
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
||||||
SMS_IR_TEMPLATE_ID=123456
|
SMS_IR_TEMPLATE_ID=123456
|
||||||
|
|
||||||
|
# GlitchTip (Sentry SDK). Empty = disabled. Use the dyolink-backend project DSN.
|
||||||
|
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
|
||||||
|
# SENTRY_ENVIRONMENT=production
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ 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
|
||||||
|
|
||||||
# SMS (sms.ir)
|
# SMS (sms.ir)
|
||||||
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
||||||
SMS_IR_TEMPLATE_ID=123456
|
SMS_IR_TEMPLATE_ID=123456
|
||||||
|
|
||||||
|
# GlitchTip (Sentry SDK). Empty = disabled. Use the dyolink-backend project DSN.
|
||||||
|
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
|
||||||
|
# SENTRY_ENVIRONMENT=staging
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
SENTRY_ENVIRONMENT: production
|
||||||
|
SENTRY_RELEASE: ${TAG:-latest}
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
SENTRY_ENVIRONMENT: staging
|
||||||
|
SENTRY_RELEASE: ${IMAGE_TAG:-latest}
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
SENTRY_ENVIRONMENT: staging
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
networks:
|
networks:
|
||||||
@@ -63,6 +64,8 @@ services:
|
|||||||
NEXT_PUBLIC_API_URL: ${STAGING_NEXT_PUBLIC_API_URL:-http://wixur.ir/api}
|
NEXT_PUBLIC_API_URL: ${STAGING_NEXT_PUBLIC_API_URL:-http://wixur.ir/api}
|
||||||
NEXT_PUBLIC_APP_URL: ${STAGING_NEXT_PUBLIC_APP_URL:-http://wixur.ir}
|
NEXT_PUBLIC_APP_URL: ${STAGING_NEXT_PUBLIC_APP_URL:-http://wixur.ir}
|
||||||
NEXT_PUBLIC_APP_NAME: ${STAGING_NEXT_PUBLIC_APP_NAME:-Dyolink}
|
NEXT_PUBLIC_APP_NAME: ${STAGING_NEXT_PUBLIC_APP_NAME:-Dyolink}
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
||||||
|
NEXT_PUBLIC_SENTRY_ENVIRONMENT: staging
|
||||||
container_name: dyolink_frontend_staging
|
container_name: dyolink_frontend_staging
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -87,6 +87,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;
|
||||||
|
}
|
||||||
|
|
||||||
# Health check endpoint (no logging)
|
# Health check endpoint (no logging)
|
||||||
location /health {
|
location /health {
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ http {
|
|||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
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
|
# Shared proxy to Docker staging
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
@@ -39,6 +41,8 @@ http {
|
|||||||
listen [::]:80 default_server;
|
listen [::]:80 default_server;
|
||||||
server_name wixur.ir www.wixur.ir localhost 127.0.0.1;
|
server_name wixur.ir www.wixur.ir localhost 127.0.0.1;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:18088;
|
proxy_pass http://127.0.0.1:18088;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -62,6 +66,8 @@ http {
|
|||||||
ssl_certificate_key ssl/wixur-key.pem;
|
ssl_certificate_key ssl/wixur-key.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:18088;
|
proxy_pass http://127.0.0.1:18088;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -75,4 +81,8 @@ http {
|
|||||||
proxy_connect_timeout 300;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
58
infrastructure/nginx/windows-gitea.wixur.snippet.conf
Normal file
58
infrastructure/nginx/windows-gitea.wixur.snippet.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,43 @@ if ! grep -q '^REGISTRY_PREFIX=.\+' .env; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
export TAG
|
export TAG
|
||||||
|
|
||||||
|
# Windows Gitea often RSTs concurrent or long pulls (connection reset by peer).
|
||||||
|
# Pull one image at a time with backoff so a flake does not fail the whole tag.
|
||||||
|
pull_one() {
|
||||||
|
local service="$1"
|
||||||
|
local attempt=1
|
||||||
|
local max=5
|
||||||
|
local delay=8
|
||||||
|
while [ "$attempt" -le "$max" ]; do
|
||||||
|
echo "Pulling $service :$TAG (attempt $attempt/$max)"
|
||||||
|
if docker compose -f docker-compose.prod.yml --env-file .env pull "$service"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq "$max" ]; then
|
||||||
|
echo "Failed to pull $service after $max attempts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Pull of $service failed; retrying in ${delay}s..."
|
||||||
|
sleep "$delay"
|
||||||
|
delay=$((delay * 2))
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
echo "Pulling backend/frontend :$TAG from Gitea (REGISTRY_PREFIX in .env)"
|
echo "Pulling backend/frontend :$TAG from Gitea (REGISTRY_PREFIX in .env)"
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env pull backend frontend
|
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
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user