File size: 8,765 Bytes
5d3c01b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
worker_processes auto;
error_log /dev/stderr warn;
pid /tmp/nginx.pid;

events {
  worker_connections 1024;
}

http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;

  log_format main '$remote_addr - [$time_local] "$request" $status $body_bytes_sent';
  access_log /dev/stdout main;

  sendfile on;
  tcp_nopush on;
  keepalive_timeout 65;

  # Serve pre-compressed assets (gzip .gz — built by vite brotliPrecompressPlugin)
  # brotli_static requires ngx_brotli module — not in Alpine nginx, use gzip fallback
  gzip_static on;
  gzip on;
  gzip_comp_level 5;
  gzip_min_length 1024;
  gzip_vary on;
  gzip_types application/json application/javascript text/css text/plain application/xml text/xml image/svg+xml;

  # Temp dirs writable by non-root
  client_body_temp_path /tmp/nginx-client-body;
  proxy_temp_path /tmp/nginx-proxy;
  fastcgi_temp_path /tmp/nginx-fastcgi;
  uwsgi_temp_path /tmp/nginx-uwsgi;
  scgi_temp_path /tmp/nginx-scgi;

  server {
    listen 8080;
    root /usr/share/nginx/html;
    # The Vite build renames the SPA entry index.html -> dashboard.html
    # (dashboardHtmlOutputPlugin, non-desktop builds), matching the Vercel
    # rewrite of the catch-all route to /dashboard.html. Keep nginx in sync.
    index dashboard.html;

    # Static assets — immutable cache
    location /assets/ {
      add_header X-Content-Type-Options "nosniff" always;
      add_header X-Frame-Options "SAMEORIGIN" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header X-XSS-Protection "1; mode=block" always;
      add_header Cache-Control "public, max-age=31536000, immutable";
      try_files $uri =404;
    }

    location /map-styles/ {
      add_header X-Content-Type-Options "nosniff" always;
      add_header X-Frame-Options "SAMEORIGIN" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header X-XSS-Protection "1; mode=block" always;
      add_header Cache-Control "public, max-age=31536000, immutable";
      try_files $uri =404;
    }

    location /data/ {
      add_header X-Content-Type-Options "nosniff" always;
      add_header X-Frame-Options "SAMEORIGIN" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header X-XSS-Protection "1; mode=block" always;
      add_header Cache-Control "public, max-age=31536000, immutable";
      try_files $uri =404;
    }

    location /textures/ {
      add_header X-Content-Type-Options "nosniff" always;
      add_header X-Frame-Options "SAMEORIGIN" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header X-XSS-Protection "1; mode=block" always;
      add_header Cache-Control "public, max-age=31536000, immutable";
      try_files $uri =404;
    }

    # Public embed entry: cross-origin embeddable, but locked down separately
    # from the dashboard diagnostic Permissions-Policy.
    location = /embed {
      add_header X-Content-Type-Options "nosniff" always;
      add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header Cache-Control "private, no-cache, must-revalidate";
      add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), accelerometer=(), autoplay=(), bluetooth=(), display-capture=(), encrypted-media=(), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()" always;
      add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'none'; frame-ancestors *; base-uri 'self'; object-src 'none'; form-action 'none'" always;
      try_files /embed.html =404;
    }

    location = /embed.html {
      add_header X-Content-Type-Options "nosniff" always;
      add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header Cache-Control "private, no-cache, must-revalidate";
      add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), accelerometer=(), autoplay=(), bluetooth=(), display-capture=(), encrypted-media=(), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()" always;
      add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'none'; frame-ancestors *; base-uri 'self'; object-src 'none'; form-action 'none'" always;
      try_files /embed.html =404;
    }

    # API proxy → Node.js local-api-server
    location /api/ {
      proxy_pass http://127.0.0.1:${LOCAL_API_PORT};
      proxy_http_version 1.1;
      proxy_set_header Host $host;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      # Pass Origin as localhost so api key checks pass for browser-origin requests
      proxy_set_header Origin http://localhost;
      # Docker/self-hosted browsers do not know the internal sidecar token.
      # Keep it in a dedicated transport header so a caller's Authorization
      # header (OAuth bearer) reaches the route handler unchanged.
      proxy_set_header X-WorldMonitor-Local-Token "${LOCAL_API_TOKEN}";
      proxy_read_timeout 120s;
      proxy_send_timeout 120s;
    }

    # SPA fallback — all other routes serve dashboard.html (the renamed entry)
    location / {
      add_header X-Content-Type-Options "nosniff" always;
      add_header X-Frame-Options "SAMEORIGIN" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header X-XSS-Protection "1; mode=block" always;
      # Dashboard CSP — keep in sync with docker/nginx-security-headers.conf and
      # vercel.json. The frontend-only image (docker/Dockerfile) applies this via
      # `include security_headers.conf`; this sidecar image inlines headers
      # per-location, so the SPA fallback must carry the dashboard CSP explicitly,
      # or the containerized dashboard runs CSP-less while /embed stays locked down.
      add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'strict-dynamic' 'nonce-wm-static-bootstrap' 'sha256-+SFBjfmi2XfnyAT3POBxf6JIKYDcNXtllPclOcaNBI0=' 'sha256-HM+RrJX5JGDL7/DcrOAMaxeZongBUfrT4ufD58oheok=' 'sha256-7oZNrsyfSuHAVU4KcnfCYgflzMCUu6NcFnN8paFIf0A=' 'sha256-lKs3SvF31U/ZDoqILsGd1YpSh0LSw9Xlo0hNHcX8Wqk=' 'sha256-jivMHZyDzdNXiV/cLq8mQuk5ce+iG0c6K5voERlie2A=' 'sha256-8+wzVMOmlqUDa/kK12C8s5ulmSbmXZ1nd8SPraxR5w4=' 'sha256-qFSeUweakvZf90cHXTBJlSgrlZOixT+/ph7kpKeRYL0=' 'wasm-unsafe-eval'; worker-src 'self' blob:; font-src 'self' data:; media-src 'self' data: blob: https:; frame-src 'self' https://www.worldmonitor.app https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://energy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://www.google.com https://webcams.windy.com https://challenges.cloudflare.com https://*.clerk.accounts.dev https://clerk.worldmonitor.app https://vercel.live https://*.vercel.app https://*.dodopayments.com https://checkout.dodopayments.com https://test.checkout.dodopayments.com https://*.hs.dodopayments.com https://*.custom.hs.dodopayments.com https://pay.google.com https://hooks.stripe.com https://js.stripe.com; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://energy.worldmonitor.app https://worldmonitor.app; base-uri 'self'; object-src 'none'; form-action 'self' https://api.worldmonitor.app" always;
      add_header Cache-Control "no-cache, no-store, must-revalidate";
      # Allow nested YouTube iframes to call requestStorageAccess().
      add_header Permissions-Policy "storage-access=(self \"https://www.youtube.com\" \"https://youtube.com\")";
      try_files $uri $uri/ /dashboard.html;
    }
  }
}