diff --git a/.gitignore b/.gitignore index 76b402f..bc2d472 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ __pycache__/ *.py[cod] node_modules/ dist/ +.env agent/.venv/ agent/build/ web-client/dist/ web-server/uploads/ +docs \ No newline at end of file diff --git a/sơ đồ database.png b/sơ đồ database.png new file mode 100644 index 0000000..e1bc866 Binary files /dev/null and b/sơ đồ database.png differ diff --git a/web-client/.dockerignore b/web-client/.dockerignore new file mode 100644 index 0000000..1714692 --- /dev/null +++ b/web-client/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.env +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +Dockerfile +.dockerignore diff --git a/web-client/.env.example b/web-client/.env.example index e49b8c0..dafcb55 100644 --- a/web-client/.env.example +++ b/web-client/.env.example @@ -1,4 +1,10 @@ # Leave empty in local dev to use Vite proxy: /api -> PACKAGE_PROXY_TARGET. +WEB_CLIENT_IMAGE_REPOSITORY=toiiiiday/robot-installer-web-client +WEB_CLIENT_CONTAINER_NAME=robot-installer-web-client +WEB_CLIENT_PORT=8080 +IMAGE_TAG=1.0.1 +DOCKER_NETWORK=robot-installer-net +PACKAGE_PROXY_TARGET=http://robot-installer-web-server:3000 + VITE_PACKAGE_BASE_URL= VITE_AGENT_BASE_URL=http://127.0.0.1:5010 -PACKAGE_PROXY_TARGET=http://localhost:3000 diff --git a/web-client/Dockerfile b/web-client/Dockerfile new file mode 100644 index 0000000..e5902c7 --- /dev/null +++ b/web-client/Dockerfile @@ -0,0 +1,27 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +ARG VITE_PACKAGE_BASE_URL= +ARG VITE_AGENT_BASE_URL=http://127.0.0.1:5010 + +RUN VITE_PACKAGE_BASE_URL="${VITE_PACKAGE_BASE_URL}" \ + VITE_AGENT_BASE_URL="${VITE_AGENT_BASE_URL}" \ + npm run build + +FROM nginx:1.27-alpine AS runtime + +ENV PACKAGE_PROXY_TARGET=http://web-server:3000 + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf.template /etc/nginx/templates/default.conf.template + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/web-client/docker-compose.yml b/web-client/docker-compose.yml new file mode 100644 index 0000000..493cb9f --- /dev/null +++ b/web-client/docker-compose.yml @@ -0,0 +1,21 @@ +services: + web-client: + image: ${WEB_CLIENT_IMAGE_REPOSITORY:-robot-installer-web-client}:${IMAGE_TAG:-local} + build: + context: . + args: + VITE_PACKAGE_BASE_URL: ${VITE_PACKAGE_BASE_URL:-} + VITE_AGENT_BASE_URL: ${VITE_AGENT_BASE_URL:-http://127.0.0.1:5010} + container_name: ${WEB_CLIENT_CONTAINER_NAME:-robot-installer-web-client} + environment: + PACKAGE_PROXY_TARGET: ${PACKAGE_PROXY_TARGET:-http://robot-installer-web-server:3000} + ports: + - "${WEB_CLIENT_PORT:-8080}:80" + networks: + - robot-installer + restart: unless-stopped + +networks: + robot-installer: + name: ${DOCKER_NETWORK:-robot-installer-net} + external: true diff --git a/web-client/nginx.conf.template b/web-client/nginx.conf.template new file mode 100644 index 0000000..fa0a78c --- /dev/null +++ b/web-client/nginx.conf.template @@ -0,0 +1,58 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 1024m; + + location = /api { + proxy_pass ${PACKAGE_PROXY_TARGET}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location ^~ /api/ { + proxy_pass ${PACKAGE_PROXY_TARGET}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /install-agent.sh { + proxy_pass ${PACKAGE_PROXY_TARGET}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location ^~ /uploads/ { + proxy_pass ${PACKAGE_PROXY_TARGET}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location ^~ /packages/ { + proxy_pass ${PACKAGE_PROXY_TARGET}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/web-server/.dockerignore b/web-server/.dockerignore new file mode 100644 index 0000000..e0b253a --- /dev/null +++ b/web-server/.dockerignore @@ -0,0 +1,11 @@ +node_modules +uploads +.env +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +Dockerfile +.dockerignore diff --git a/web-server/.env.example b/web-server/.env.example index 5ff4158..de13423 100644 --- a/web-server/.env.example +++ b/web-server/.env.example @@ -1,4 +1,9 @@ PORT=3000 +WEB_SERVER_IMAGE_REPOSITORY=toiiiiday/robot-installer-web-server +WEB_SERVER_CONTAINER_NAME=robot-installer-web-server +WEB_SERVER_PORT=3000 +IMAGE_TAG=1.0.1 +DOCKER_NETWORK=robot-installer-net SQLSERVER_HOST=172.20.235.176 SQLSERVER_PORT=1433 SQLSERVER_DATABASE=RobotInstaller @@ -8,6 +13,7 @@ SQLSERVER_ENCRYPT=false SQLSERVER_TRUST_SERVER_CERTIFICATE=true AUTH_SECRET=change_this_to_a_long_random_value SESSION_MAX_AGE_MS=28800000 +SESSION_COOKIE_SECURE=false EMAIL_CONFIRMATION_EXPIRES_MS=86400000 APP_BASE_URL=http://localhost:3000 diff --git a/web-server/Dockerfile b/web-server/Dockerfile new file mode 100644 index 0000000..b065d53 --- /dev/null +++ b/web-server/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-alpine AS dependencies + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +FROM node:22-alpine AS runtime + +ENV NODE_ENV=production +ENV PORT=3000 + +WORKDIR /app + +COPY --from=dependencies /app/node_modules ./node_modules +COPY . . + +RUN mkdir -p uploads/packages/agent \ + && chown -R node:node uploads + +USER node + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["npm", "start"] diff --git a/web-server/docker-compose.yml b/web-server/docker-compose.yml new file mode 100644 index 0000000..df0aa4a --- /dev/null +++ b/web-server/docker-compose.yml @@ -0,0 +1,29 @@ +services: + web-server: + image: ${WEB_SERVER_IMAGE_REPOSITORY:-robot-installer-web-server}:${IMAGE_TAG:-local} + build: + context: . + container_name: ${WEB_SERVER_CONTAINER_NAME:-robot-installer-web-server} + env_file: + - ./.env + environment: + NODE_ENV: production + PORT: 3000 + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + WEB_CLIENT_ORIGINS: ${WEB_CLIENT_ORIGINS:-http://localhost:8080,http://localhost:5173,http://localhost:4173} + ports: + - "${WEB_SERVER_PORT:-3000}:3000" + volumes: + - web_server_uploads:/app/uploads + networks: + - robot-installer + restart: unless-stopped + +networks: + robot-installer: + name: ${DOCKER_NETWORK:-robot-installer-net} + external: true + +volumes: + web_server_uploads: diff --git a/web-server/server.js b/web-server/server.js index 34868f4..56bcd03 100644 --- a/web-server/server.js +++ b/web-server/server.js @@ -18,6 +18,7 @@ const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join( const authCookieName = 'robot_installer_session'; const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8); const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || 'robot-installer-dev-secret'; +const secureSessionCookie = getBooleanEnv(process.env.SESSION_COOKIE_SECURE, process.env.NODE_ENV === 'production'); const publicApiCorsOrigins = getCsvEnv(process.env.WEB_CLIENT_ORIGINS || process.env.PUBLIC_API_CORS_ORIGINS, [ 'https://robot.installer', 'http://localhost:5173', @@ -28,6 +29,10 @@ const agentVersionCollator = new Intl.Collator('en', { sensitivity: 'base' }); +app.get('/healthz', (req, res) => { + res.status(200).json({ status: 'ok' }); +}); + fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(agentPackageDir, { recursive: true }); @@ -156,6 +161,11 @@ function getCsvEnv(value, fallback) { .filter(Boolean); } +function getBooleanEnv(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + function isPublicApiCorsPath(pathname) { return pathname === '/api/apps' || pathname.startsWith('/api/apps/') @@ -345,7 +355,7 @@ function setAuthCookie(res, user) { httpOnly: true, maxAge: sessionMaxAgeMs, sameSite: 'lax', - secure: process.env.NODE_ENV === 'production' + secure: secureSessionCookie }); } @@ -353,7 +363,7 @@ function clearAuthCookie(res) { res.clearCookie(authCookieName, { httpOnly: true, sameSite: 'lax', - secure: process.env.NODE_ENV === 'production' + secure: secureSessionCookie }); }