diff --git a/docker/Dockerfile b/docker/Dockerfile index c67b6686..f29cfb3b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -53,16 +53,18 @@ ENV DATA_DIR=/app/data \ RUN apt-get update && apt-get install -y nginx gettext-base openssl && \ rm -rf /var/lib/apt/lists/* && \ - mkdir -p /app/data /app/uploads && \ - chown -R node:node /app/data /app/uploads && \ - useradd -r -s /bin/false nginx + mkdir -p /app/data /app/uploads /app/nginx /app/nginx/logs /app/nginx/cache /app/nginx/client_body && \ + chown -R node:node /app && \ + chmod 755 /app/data /app/uploads /app/nginx && \ + touch /app/nginx/nginx.conf && \ + chown node:node /app/nginx/nginx.conf -COPY docker/nginx.conf /etc/nginx/nginx.conf -COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf +COPY docker/nginx.conf /app/nginx/nginx.conf.template +COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template -COPY --chown=nginx:nginx --from=frontend-builder /app/dist /usr/share/nginx/html -COPY --chown=nginx:nginx --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales -COPY --chown=nginx:nginx --from=frontend-builder /app/public/fonts /usr/share/nginx/html/fonts +COPY --chown=node:node --from=frontend-builder /app/dist /app/html +COPY --chown=node:node --from=frontend-builder /app/src/locales /app/html/locales +COPY --chown=node:node --from=frontend-builder /app/public/fonts /app/html/fonts COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend @@ -74,4 +76,7 @@ EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh + +USER node + CMD ["/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 67d389c2..165c9ee2 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -11,24 +11,21 @@ echo "Configuring web UI to run on port: $PORT" if [ "$ENABLE_SSL" = "true" ]; then echo "SSL enabled - using HTTPS configuration with redirect" - NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf" + NGINX_CONF_SOURCE="/app/nginx/nginx-https.conf.template" else echo "SSL disabled - using HTTP-only configuration (default)" - NGINX_CONF_SOURCE="/etc/nginx/nginx.conf" + NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template" fi -envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /etc/nginx/nginx.conf.tmp -mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf +envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /app/nginx/nginx.conf mkdir -p /app/data /app/uploads -chown -R node:node /app/data /app/uploads -chmod 755 /app/data /app/uploads +chmod 755 /app/data /app/uploads 2>/dev/null || true if [ "$ENABLE_SSL" = "true" ]; then echo "Checking SSL certificate configuration..." mkdir -p /app/data/ssl - chown -R node:node /app/data/ssl - chmod 755 /app/data/ssl + chmod 755 /app/data/ssl 2>/dev/null || true DOMAIN=${SSL_DOMAIN:-localhost} @@ -84,7 +81,6 @@ EOF chmod 600 /app/data/ssl/termix.key chmod 644 /app/data/ssl/termix.crt - chown node:node /app/data/ssl/termix.key /app/data/ssl/termix.crt rm -f /app/data/ssl/openssl.conf @@ -93,7 +89,7 @@ EOF fi echo "Starting nginx..." -nginx +nginx -c /app/nginx/nginx.conf echo "Starting backend services..." cd /app @@ -110,11 +106,7 @@ else echo "Warning: package.json not found" fi -if command -v su-exec > /dev/null 2>&1; then - su-exec node node dist/backend/backend/starter.js -else - su -s /bin/sh node -c "node dist/backend/backend/starter.js" -fi +node dist/backend/backend/starter.js echo "All services started" diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 0f675a6f..0e2c8575 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -1,11 +1,22 @@ +pid /app/nginx/nginx.pid; +error_log /app/nginx/logs/error.log warn; + events { worker_connections 1024; } http { - include mime.types; + include /etc/nginx/mime.types; default_type application/octet-stream; + access_log /app/nginx/logs/access.log; + + client_body_temp_path /app/nginx/client_body; + proxy_temp_path /app/nginx/proxy_temp; + fastcgi_temp_path /app/nginx/fastcgi_temp; + uwsgi_temp_path /app/nginx/uwsgi_temp; + scgi_temp_path /app/nginx/scgi_temp; + sendfile on; keepalive_timeout 65; client_header_timeout 300s; @@ -37,9 +48,17 @@ http { add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + root /app/html; + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + location / { - root /usr/share/nginx/html; + root /app/html; index index.html index.htm; + try_files $uri $uri/ /index.html; } location ~* \.map$ { @@ -350,7 +369,7 @@ http { error_page 500 502 503 504 /50x.html; location = /50x.html { - root /usr/share/nginx/html; + root /app/html; } } } diff --git a/docker/nginx.conf b/docker/nginx.conf index 4b72f21c..e0c6701f 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,11 +1,22 @@ +pid /app/nginx/nginx.pid; +error_log /app/nginx/logs/error.log warn; + events { worker_connections 1024; } http { - include mime.types; + include /etc/nginx/mime.types; default_type application/octet-stream; + access_log /app/nginx/logs/access.log; + + client_body_temp_path /app/nginx/client_body; + proxy_temp_path /app/nginx/proxy_temp; + fastcgi_temp_path /app/nginx/fastcgi_temp; + uwsgi_temp_path /app/nginx/uwsgi_temp; + scgi_temp_path /app/nginx/scgi_temp; + sendfile on; keepalive_timeout 65; client_header_timeout 300s; @@ -27,14 +38,14 @@ http { add_header X-XSS-Protection "1; mode=block" always; location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - root /usr/share/nginx/html; + root /app/html; expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } location / { - root /usr/share/nginx/html; + root /app/html; index index.html index.htm; try_files $uri $uri/ /index.html; } @@ -347,7 +358,7 @@ http { error_page 500 502 503 504 /50x.html; location = /50x.html { - root /usr/share/nginx/html; + root /app/html; } } } diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 814f06bc..c4588c70 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -15,6 +15,9 @@ import { sshCredentialUsage, recentActivity, snippets, + snippetFolders, + sshFolders, + commandHistory, roles, userRoles, } from "../db/schema.js"; @@ -136,6 +139,84 @@ function isNonEmptyString(val: unknown): val is string { const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); +/** + * Comprehensive user deletion utility that ensures all related data is deleted + * in proper order to avoid foreign key constraint errors. + * + * This function explicitly deletes all user-related data before deleting the user record. + * It wraps everything in a transaction for atomicity. + * + * @param userId - The ID of the user to delete + * @returns Promise + * @throws Error if deletion fails + */ +async function deleteUserAndRelatedData(userId: string): Promise { + try { + authLogger.info("Starting comprehensive user data deletion", { + operation: "delete_user_and_related_data_start", + userId, + }); + + // Delete all related data in proper order to avoid FK constraint errors + // Order matters due to foreign key relationships + + // 1. Delete credential usage logs + await db + .delete(sshCredentialUsage) + .where(eq(sshCredentialUsage.userId, userId)); + + // 2. Delete file manager data + await db + .delete(fileManagerRecent) + .where(eq(fileManagerRecent.userId, userId)); + await db + .delete(fileManagerPinned) + .where(eq(fileManagerPinned.userId, userId)); + await db + .delete(fileManagerShortcuts) + .where(eq(fileManagerShortcuts.userId, userId)); + + // 3. Delete activity and alerts + await db.delete(recentActivity).where(eq(recentActivity.userId, userId)); + await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId)); + + // 4. Delete snippets and snippet folders + await db.delete(snippets).where(eq(snippets.userId, userId)); + await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId)); + + // 5. Delete SSH folders + await db.delete(sshFolders).where(eq(sshFolders.userId, userId)); + + // 6. Delete command history + await db.delete(commandHistory).where(eq(commandHistory.userId, userId)); + + // 7. Delete SSH data and credentials + await db.delete(sshData).where(eq(sshData.userId, userId)); + await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId)); + + // 8. Delete user-specific settings (encryption keys, etc.) + db.$client + .prepare("DELETE FROM settings WHERE key LIKE ?") + .run(`user_%_${userId}`); + + // 9. Finally, delete the user record + // Note: Sessions, user_roles, host_access, audit_logs, and session_recordings + // will be automatically deleted via CASCADE DELETE foreign key constraints + await db.delete(users).where(eq(users.id, userId)); + + authLogger.success("User and all related data deleted successfully", { + operation: "delete_user_and_related_data_complete", + userId, + }); + } catch (error) { + authLogger.error("Failed to delete user and related data", error, { + operation: "delete_user_and_related_data_failed", + userId, + }); + throw error; + } +} + // Route: Create traditional user (username/password) // POST /users/create router.post("/create", async (req, res) => { @@ -874,17 +955,24 @@ router.get("/oidc/callback", async (req, res) => { roleName: defaultRoleName, }); } else { - authLogger.warn("Default role not found during OIDC user registration", { - operation: "assign_default_role_oidc", - userId: id, - roleName: defaultRoleName, - }); + authLogger.warn( + "Default role not found during OIDC user registration", + { + operation: "assign_default_role_oidc", + userId: id, + roleName: defaultRoleName, + }, + ); } } catch (roleError) { - authLogger.error("Failed to assign default role to OIDC user", roleError, { - operation: "assign_default_role_oidc", - userId: id, - }); + authLogger.error( + "Failed to assign default role to OIDC user", + roleError, + { + operation: "assign_default_role_oidc", + userId: id, + }, + ); // Don't fail user creation if role assignment fails } @@ -2324,36 +2412,8 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { const targetUserId = targetUser[0].id; - try { - await db - .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, targetUserId)); - await db - .delete(fileManagerRecent) - .where(eq(fileManagerRecent.userId, targetUserId)); - await db - .delete(fileManagerPinned) - .where(eq(fileManagerPinned.userId, targetUserId)); - await db - .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, targetUserId)); - await db - .delete(recentActivity) - .where(eq(recentActivity.userId, targetUserId)); - await db - .delete(dismissedAlerts) - .where(eq(dismissedAlerts.userId, targetUserId)); - await db.delete(snippets).where(eq(snippets.userId, targetUserId)); - await db.delete(sshData).where(eq(sshData.userId, targetUserId)); - await db - .delete(sshCredentials) - .where(eq(sshCredentials.userId, targetUserId)); - } catch (cleanupError) { - authLogger.error(`Cleanup failed for user ${username}:`, cleanupError); - throw cleanupError; - } - - await db.delete(users).where(eq(users.id, targetUserId)); + // Use the comprehensive deletion utility + await deleteUserAndRelatedData(targetUserId); authLogger.success( `User ${username} deleted by admin ${adminUser[0].username}`, @@ -2765,18 +2825,12 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { }); } + // Revoke all sessions and logout the OIDC user before deletion await authManager.revokeAllUserSessions(oidcUserId); authManager.logoutUser(oidcUserId); - await db - .delete(recentActivity) - .where(eq(recentActivity.userId, oidcUserId)); - - await db.delete(users).where(eq(users.id, oidcUserId)); - - db.$client - .prepare("DELETE FROM settings WHERE key LIKE ?") - .run(`user_%_${oidcUserId}`); + // Use the comprehensive deletion utility to ensure all data is properly deleted + await deleteUserAndRelatedData(oidcUserId); try { const { saveMemoryDatabaseToFile } = await import("../db/index.js");