Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker/all-in-one/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ COPY --from=dotnet-build /publish/worker /app/worker
COPY docker/all-in-one/nginx.conf /etc/nginx/conf.d/default.conf
# Same security-header snippet the frontend image uses, so the two never drift apart.
COPY frontend/nginx-security-headers.conf /etc/nginx/snippets/garagestack-security-headers.conf
# And the same locations, behind this image's own upstream (docker/all-in-one/nginx.conf).
COPY frontend/nginx-app.conf /etc/nginx/snippets/garagestack-app.conf
COPY docker/all-in-one/mosquitto.conf /etc/mosquitto/conf.d/garagestack.conf
COPY docker/all-in-one/supervisord.conf /etc/supervisor/supervisord.conf
COPY --chmod=755 \
Expand Down
75 changes: 4 additions & 71 deletions docker/all-in-one/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -17,76 +17,9 @@ server {
# nginx does not inherit add_header into such a location.
include /etc/nginx/snippets/garagestack-security-headers.conf;

gzip on;
gzip_types text/plain text/css application/json application/manifest+json application/javascript text/xml application/xml image/svg+xml;
# The API process inside this container.
set $api_upstream http://127.0.0.1:9000;

# GarageStack API -- .NET process listens on localhost:9000
location /api/ {
proxy_pass http://127.0.0.1:9000/api/;
proxy_http_version 1.1;
# $http_host, not $host: $host drops the port, and a redirect URI missing the port is
# rejected by the identity provider.
proxy_set_header Host $http_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 $garagestack_forwarded_proto;
}

# SignalR WebSocket hubs
location /hubs/ {
proxy_pass http://127.0.0.1:9000/hubs/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_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 $garagestack_forwarded_proto;
proxy_read_timeout 86400;
}

# SPA -- all unmatched paths fall back to index.html
location / {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store" always;
try_files $uri $uri/ /index.html;
}

# SW and manifest must never be HTTP-cached: the browser needs to fetch
# them fresh on every load to detect app updates. Long-caching these causes
# the SW to keep precaching stale asset hashes after a new deployment.
location = /sw.js {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri =404;
}
location = /registerSW.js {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri =404;
}
location = /manifest.webmanifest {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
# mime.types has no entry for .webmanifest, so it went out as application/octet-stream.
# With the types emptied here, default_type names it instead.
types { }
default_type application/manifest+json;
try_files $uri =404;
}

# Vite's build output: every file name carries a content hash, so a release brings new URLs
# and these can be cached for good.
location ^~ /assets/ {
include /etc/nginx/snippets/garagestack-security-headers.conf;
expires 1y;
add_header Cache-Control "public, immutable";
}

# The icons and screenshots from public/ keep their names across releases, so a changed
# one has to reach browsers within a day rather than a year.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
include /etc/nginx/snippets/garagestack-security-headers.conf;
expires 1d;
}
# The locations, shared with the frontend image (frontend/nginx-app.conf).
include /etc/nginx/snippets/garagestack-app.conf;
}
6 changes: 6 additions & 0 deletions documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ process-local one doesn't already provide. The caches, each in memory or in Post
widget, and every SignalR broadcast) uses a short-TTL `IMemoryCache` entry, invalidated
immediately on every telemetry write so it can never serve data older than the write that
triggered a SignalR broadcast.
- `ITelemetryRepository.GetDailyStatsAsync` (the statistics page, the tyre history and the
slow-puncture check) keeps each finished day in `IMemoryCache`, per vehicle and time zone, and
reads only the span from the first day it lacks to the last: a page opened again reads today
rather than the whole period. A finished day cannot change, since every row is stamped when it
arrives; a day counts as finished an hour after its local midnight, which covers the merge
window folding a message just after midnight into a row stamped before it.
- Map POI tiles (charging stations, fuel stations, service areas, speed cameras) are cached in Postgres itself
(`PoiCacheTile`/`PoiItem`), not in memory, since that data needs to survive process restarts
and be queried by bounding box - a job a plain in-memory cache isn't suited for anyway. The
Expand Down
1 change: 1 addition & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ RUN echo "os patch date: ${OS_PATCH_DATE}" && apk upgrade --no-cache
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY nginx-security-headers.conf /etc/nginx/snippets/garagestack-security-headers.conf
COPY nginx-app.conf /etc/nginx/snippets/garagestack-app.conf
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=5 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1/ || exit 1
85 changes: 85 additions & 0 deletions frontend/nginx-app.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Everything inside the server block that the frontend image (frontend/nginx.conf) and the
# all-in-one image (docker/all-in-one/nginx.conf) share. Each includes it from its own server
# block, after setting $api_upstream to where its API listens: proxy_pass with a variable and no
# path passes the request URI on unchanged.
#
# The security headers are included again in every location that sets its own add_header,
# because nginx does not inherit add_header into such a location.

# Text is sent compressed. The build writes a .gz beside every larger file (see
# frontend/scripts/precompress.mjs), compressed once at the highest level, and gzip_static
# serves that instead of compressing each response again; anything without one, the API's
# JSON included, is compressed on the fly.
gzip on;
gzip_static on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/manifest+json application/javascript text/xml application/xml image/svg+xml;

location /api/ {
proxy_pass $api_upstream;
proxy_http_version 1.1;
# $http_host, not $host: $host drops the port, and a redirect URI missing the port is
# rejected by the identity provider.
proxy_set_header Host $http_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 $garagestack_forwarded_proto;
}

# SignalR WebSocket hubs
location /hubs/ {
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_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 $garagestack_forwarded_proto;
proxy_read_timeout 86400;
}

location / {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store" always;
try_files $uri $uri/ /index.html;
}

# SW and manifest must never be HTTP-cached: the browser needs to fetch
# them fresh on every load to detect app updates. Long-caching these causes
# the SW to keep precaching stale asset hashes after a new deployment.
location = /sw.js {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri =404;
}
location = /registerSW.js {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri =404;
}
location = /manifest.webmanifest {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
# mime.types has no entry for .webmanifest, so it went out as application/octet-stream.
# With the types emptied here, default_type names it instead.
types { }
default_type application/manifest+json;
try_files $uri =404;
}

# Vite's build output: every file name carries a content hash, so a release brings new URLs
# and these can be cached for good.
location ^~ /assets/ {
include /etc/nginx/snippets/garagestack-security-headers.conf;
expires 1y;
add_header Cache-Control "public, immutable";
}

# The icons and screenshots from public/ keep their names across releases, so a changed
# one has to reach browsers within a day rather than a year.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
include /etc/nginx/snippets/garagestack-security-headers.conf;
expires 1d;
}
75 changes: 4 additions & 71 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -24,76 +24,9 @@ server {
# file again, because nginx does not inherit add_header into such a location.
include /etc/nginx/snippets/garagestack-security-headers.conf;

gzip on;
gzip_types text/plain text/css application/json application/manifest+json application/javascript text/xml application/xml image/svg+xml;
# Where the API container answers. Through a variable, so nginx resolves the name with the
# resolver above instead of once at startup.
set $api_upstream http://api:8080;

location /api/ {
set $api_upstream http://api:8080;
proxy_pass $api_upstream;
proxy_http_version 1.1;
# $http_host, not $host: $host drops the port, and a redirect URI missing the port is
# rejected by the identity provider.
proxy_set_header Host $http_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 $garagestack_forwarded_proto;
}

# SignalR WebSocket hubs
location /hubs/ {
set $api_upstream http://api:8080;
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_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 $garagestack_forwarded_proto;
proxy_read_timeout 86400;
}

location / {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store" always;
try_files $uri $uri/ /index.html;
}

# SW and manifest must never be HTTP-cached: the browser needs to fetch
# them fresh on every load to detect app updates. Long-caching these causes
# the SW to keep precaching stale asset hashes after a new deployment.
location = /sw.js {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri =404;
}
location = /registerSW.js {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri =404;
}
location = /manifest.webmanifest {
include /etc/nginx/snippets/garagestack-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
# mime.types has no entry for .webmanifest, so it went out as application/octet-stream.
# With the types emptied here, default_type names it instead.
types { }
default_type application/manifest+json;
try_files $uri =404;
}

# Vite's build output: every file name carries a content hash, so a release brings new URLs
# and these can be cached for good.
location ^~ /assets/ {
include /etc/nginx/snippets/garagestack-security-headers.conf;
expires 1y;
add_header Cache-Control "public, immutable";
}

# The icons and screenshots from public/ keep their names across releases, so a changed
# one has to reach browsers within a day rather than a year.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
include /etc/nginx/snippets/garagestack-security-headers.conf;
expires 1d;
}
include /etc/nginx/snippets/garagestack-app.conf;
}
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"test:unit": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"build-only": "vite build",
"build-only": "vite build && node scripts/precompress.mjs dist",
"type-check": "vue-tsc --build",
"lint": "pnpm run lint:oxlint && pnpm run lint:eslint && pnpm run lint:css",
"lint:oxlint": "oxlint . --fix",
Expand Down
37 changes: 37 additions & 0 deletions frontend/scripts/precompress.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Writes a gzip copy beside every compressible file of the build, for nginx's gzip_static
// (frontend/nginx-app.conf): compressed once here at the highest level instead of at nginx's
// default level 1 on every response. Usage: node scripts/precompress.mjs [dir], dist by default.
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { constants, gzipSync } from 'node:zlib'

const COMPRESSIBLE = /\.(?:js|mjs|css|html|svg|json|webmanifest|txt)$/
// Below nginx's gzip_min_length the header costs more than the bytes it saves.
const MIN_BYTES = 1024

async function* files(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) yield* files(path)
else if (entry.isFile()) yield path
}
}

const root = process.argv[2] ?? 'dist'
let written = 0
let before = 0
let after = 0
for await (const path of files(root)) {
if (!COMPRESSIBLE.test(path) || (await stat(path)).size < MIN_BYTES) continue
const source = await readFile(path)
const compressed = gzipSync(source, { level: constants.Z_BEST_COMPRESSION })
// A file that does not shrink is better sent as it is.
if (compressed.length >= source.length) continue
await writeFile(`${path}.gz`, compressed)
written += 1
before += source.length
after += compressed.length
}

const kib = (bytes) => `${(bytes / 1024).toFixed(0)} KiB`
console.log(`precompress: ${written} files, ${kib(before)} -> ${kib(after)} gzipped`)
Loading
Loading