# Optional dedicated embed origin — uncomment and set server_name (e.g. embed.example.com). # Serve only embed.html + static assets; proxy /api to the WeKnora backend. # See docs/embed-subdomain.md # # server { # listen 80; # server_name embed.example.com; # client_max_body_size ${MAX_FILE_SIZE}; # location = /weknora-widget.js { root /usr/share/nginx/html; } # location ^~ /embed/ { root /usr/share/nginx/html; try_files $uri $uri/ /embed.html; } # location ^~ /assets/ { root /usr/share/nginx/html; add_header Cache-Control "public, max-age=31536000, immutable"; } # location /api/ { proxy_pass http://weknora-backend:8080; proxy_set_header Host $host; } # } map $http_x_forwarded_proto $original_scheme { default $scheme; https https; http http; } # Classify the original request before SPA rewrites without logging its URL. map $request_uri $ingress_route_class { default other; ~^/api/v1/auth/me(?:\?|$) auth_me; ~^/api/v1/access/me(?:\?|$) access_me; ~^/api/v1/system/capabilities(?:\?|$) capabilities; ~^/api/v1/workbench/resources/[^/?]+(?:\?|$) resource_list; ~^/api/v1/workbench/resources/[^/?]+/[^/?]+(?:\?|$) resource_detail; ~^/api(?:/|\?|$) api; ~^/assets(?:/|\?|$) asset; ~^/tdesign-icons(?:/|\?|$) icon; ~^/embed(?:/|\?|$) embed; ~^/r(?:/|\?|$) resource_link; ~^/files(?:/|\?|$) file; ~^/config\.js(?:\?|$) config; ~^/release\.json(?:\?|$) release; ~^/weknora-widget\.js(?:\?|$) widget; ~^/(?:$|\?) shell; ~^/(?:index\.html|login|register|admin|platform)(?:/|\?|$) shell; } # Local clients can supply this header too; retain only a bounded ray ID. map $http_cf_ray $ingress_cf_ray { default "-"; "~^[0-9a-fA-F]{16,32}-[A-Z]{3}$" $http_cf_ray; } map $upstream_connect_time $ingress_upstream_connect_time { default $upstream_connect_time; "" "-"; } map $upstream_header_time $ingress_upstream_header_time { default $upstream_header_time; "" "-"; } map $upstream_response_time $ingress_upstream_response_time { default $upstream_response_time; "" "-"; } log_format ingress_timing escape=json '{"time":"$time_iso8601","route":"$ingress_route_class","status":$status,' '"request_time":"$request_time","upstream_connect_time":"$ingress_upstream_connect_time",' '"upstream_header_time":"$ingress_upstream_header_time","upstream_response_time":"$ingress_upstream_response_time",' '"cf_ray":"$ingress_cf_ray"}'; server { listen 80; server_name localhost; resolver 127.0.0.11 valid=10s ipv6=off; set $app_backend ${APP_SCHEME}://${APP_HOST}:${APP_PORT}; # Default 50M, configured via MAX_FILE_SIZE_MB env var client_max_body_size ${MAX_FILE_SIZE}; # 启用 gzip 压缩静态资源 (前端 index.js 单文件 ~1MB, 不压实测 20s+, # 压缩后约 200-300KB, 大陆同地域低带宽机器加载从 25s 降到 3-5s) gzip on; gzip_vary on; gzip_min_length 1024; gzip_comp_level 6; gzip_proxied any; gzip_types text/plain text/css text/javascript application/javascript application/json application/xml application/rss+xml image/svg+xml font/ttf font/otf; # 安全头配置 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; # 错误日志配置 error_log /var/log/nginx/error.log warn; access_log /var/log/nginx/access.log ingress_timing; # Static widget loader for third-party sites location = /weknora-widget.js { root /usr/share/nginx/html; add_header Cache-Control "public, max-age=3600" always; add_header X-Content-Type-Options "nosniff" always; } # Internal fallback target for /embed/* routes. Keep it separate from the # main SPA location so embed pages do not inherit X-Frame-Options. location = /embed.html { internal; root /usr/share/nginx/html; add_header Cache-Control "no-cache, must-revalidate" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # Embed widget pages may be loaded inside third-party iframes (lightweight embed.html) location ^~ /embed/ { root /usr/share/nginx/html; try_files $uri $uri/ /embed.html; add_header Cache-Control "no-cache, must-revalidate" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # 前端静态文件 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # index.html 及 SPA fallback 不可缓存,否则前端升级后用户看不到新版本 add_header Cache-Control "no-cache, must-revalidate" always; # nginx add_header 不从上层继承,需要在 location 内重复声明安全头 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # Runtime values can change between deployments without changing this URL. location = /config.js { root /usr/share/nginx/html; try_files $uri =404; add_header Cache-Control "no-store" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # Change the versioned directory when updating the bundled icon assets. location ~ ^/tdesign-icons/[0-9]+\.[0-9]+\.[0-9]+/ { root /usr/share/nginx/html; try_files $uri =404; error_page 404 = @missing_asset; add_header Cache-Control "public, max-age=31536000, immutable"; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # Vite 构建产物 /assets/* 带 hash 文件名,可长期缓存 location ^~ /assets/ { root /usr/share/nginx/html; try_files $uri =404; error_page 404 = @missing_asset; add_header Cache-Control "public, max-age=31536000, immutable"; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } location @missing_asset { add_header Cache-Control "no-store" always; add_header X-Content-Type-Options "nosniff" always; return 404; } # 本地存储文件代理到后端服务(用于渲染 markdown 中的图片) # 精确匹配 /files,避免 Nginx 自动补 / 触发 301 location = /files { proxy_pass $app_backend; 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 $original_scheme; proxy_buffering off; proxy_cache off; add_header Cache-Control "no-store" always; add_header X-Content-Type-Options "nosniff" always; } # 资源能力短链 /r/:IM 渠道渲染 resource:// 图片依赖此路由, # 缺此段会落进 SPA fallback 返回空白页,图片加载不出来。 location ^~ /r/ { proxy_pass $app_backend; 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 $original_scheme; proxy_cache off; add_header Cache-Control "no-store" always; add_header X-Content-Type-Options "nosniff" always; } # 技能 zip 上传的两条集合路由,body 上限放宽到 MAX_SKILL_BUNDLE_SIZE。 # 单独成一个 location 而不是抬高全站上限:client_max_body_size 是按 # location 生效的,写在 server 级会让知识库上传等端点也能收到技能包大小的 # body。这里放在 /api/ 之前,正则 location 优先于前缀 location 命中。 # 只匹配集合本身(POST 上传 / GET 列表),不带 /install、PATCH 等子路径: # 那些是小 JSON,必须继续走下面 /api/ 的 MAX_FILE_SIZE。 location ~ ^/api/v1/(?:skills/catalog|sandbox-configs/[^/]+/skills)/?$ { client_max_body_size ${MAX_SKILL_BUNDLE_SIZE}; # 正则 location 的 proxy_pass 不能带 URI,原始路径原样透传, # 与 /api/ 那条 `proxy_pass .../api/` 的改写结果一致。 proxy_pass $app_backend; include /etc/nginx/api-proxy.conf; } # API请求代理到后端服务 # APP_SCHEME 默认 http,远程 HTTPS 后端可设为 https location /api/ { proxy_pass $app_backend; include /etc/nginx/api-proxy.conf; } # 错误页面 error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } }