Compare commits
3 Commits
c1a07a75fa
...
da903db32a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da903db32a | ||
|
|
9926d3ab55 | ||
|
|
e95a42dbed |
@@ -35,7 +35,6 @@ jobs:
|
|||||||
# taken on the machine itself. Known occupants of that host:
|
# taken on the machine itself. Known occupants of that host:
|
||||||
# 8000, 9443 Portainer (the Edge tunnel and its UI)
|
# 8000, 9443 Portainer (the Edge tunnel and its UI)
|
||||||
# 18080/18081 the production dtf-cloud stack (docker-compose.yml defaults)
|
# 18080/18081 the production dtf-cloud stack (docker-compose.yml defaults)
|
||||||
# 8080/8081 deploy/stack.yaml defaults
|
|
||||||
# 9000/9001 MinIO defaults elsewhere
|
# 9000/9001 MinIO defaults elsewhere
|
||||||
# This block avoids all of them. Ephemeral ports are not an option: the
|
# This block avoids all of them. Ephemeral ports are not an option: the
|
||||||
# published port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP
|
# published port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ DTF follows the same operating model as Graphs and ComporHUB: Gitea builds
|
|||||||
prebuilt images, pushes them to the Gitea registry, and calls one Portainer
|
prebuilt images, pushes them to the Gitea registry, and calls one Portainer
|
||||||
webhook. Portainer owns and redeploys one Docker Swarm stack named `dtf-cloud`.
|
webhook. Portainer owns and redeploys one Docker Swarm stack named `dtf-cloud`.
|
||||||
|
|
||||||
The production stack is `deploy/stack.yaml`. It contains Site, Kanban, API,
|
The deployed stack is the repository's `docker-compose.yml`, which the
|
||||||
|
`dtf-cloud` Portainer stack points at. `deploy/stack.yaml` is a more hardened
|
||||||
|
definition that supplies every credential as a Docker secret rather than an
|
||||||
|
environment variable; it is not currently deployed. See `ROADMAP.md` 2.12 before
|
||||||
|
assuming either is authoritative.
|
||||||
|
|
||||||
|
`deploy/stack.yaml` contains Site, Kanban, API,
|
||||||
worker, PostgreSQL, ClamAV, and a one-time database initializer. Production uses
|
worker, PostgreSQL, ClamAV, and a one-time database initializer. Production uses
|
||||||
Cloudflare R2, so MinIO is not part of this stack.
|
Cloudflare R2, so MinIO is not part of this stack.
|
||||||
|
|
||||||
|
|||||||
47
ROADMAP.md
47
ROADMAP.md
@@ -222,16 +222,57 @@ refactor: `'This runtime only supports APP_ENV=local'`,
|
|||||||
- Replace marker matching with behavioural assertions (import the module, assert
|
- Replace marker matching with behavioural assertions (import the module, assert
|
||||||
the adapter classes in use).
|
the adapter classes in use).
|
||||||
|
|
||||||
|
### `[x]` 2.12 — Two divergent stack definitions; the docs named the wrong one
|
||||||
|
|
||||||
|
Found 2026-09-21 by asking which file Portainer deploys. `PORTAINER.md` called
|
||||||
|
`deploy/stack.yaml` "the production stack"; the deployed file is the repository's
|
||||||
|
`docker-compose.yml`. `deploy/stack.yaml` came from the first commit and was never
|
||||||
|
deployed — it supplied credentials as Docker secrets where the deployed file uses
|
||||||
|
plain environment variables.
|
||||||
|
|
||||||
|
**Decision (2026-09-21): keep `docker-compose.yml`, delete `deploy/stack.yaml`.**
|
||||||
|
|
||||||
|
The gain from Docker secrets here is narrower than it sounds. It keeps values out
|
||||||
|
of `docker inspect` and the Portainer UI, but `local/secrets.py` loads them into
|
||||||
|
the process environment anyway, and anyone who can read `docker inspect` is
|
||||||
|
already root or in the docker group and could read the secret files directly. The
|
||||||
|
operator is the only Portainer user, so the main benefit — limiting what a
|
||||||
|
lower-privileged console user can see — does not apply. Maintaining two
|
||||||
|
definitions that drift was the larger real cost.
|
||||||
|
|
||||||
|
`local/secrets.py` stays. It is inert against the deployed file and costs nothing,
|
||||||
|
and it means a stack can switch to Docker secrets later without a code change.
|
||||||
|
|
||||||
|
Still open: **rotate the R2 secret key.** Not because of Portainer, but because it
|
||||||
|
grants read and write over every customer's artwork and has been readable from the
|
||||||
|
stack environment for some time. The operator password is worth rotating with it.
|
||||||
|
|
||||||
### `[x]` 2.6 — Base images are not pinned `(F10)`
|
### `[x]` 2.6 — Base images are not pinned `(F10)`
|
||||||
|
|
||||||
Dockerfiles default to mutable `python:3.12-slim` / `nginx:1.28-alpine`, the
|
Dockerfiles default to mutable `python:3.12-slim` / `nginx:1.28-alpine`, the
|
||||||
workflow passes no digest build-args, and `--pull` makes builds non-reproducible —
|
workflow passes no digest build-args, and `--pull` makes builds non-reproducible —
|
||||||
while `PORTAINER.md` documents digest-pinned immutable bases.
|
while `PORTAINER.md` documents digest-pinned immutable bases.
|
||||||
|
|
||||||
### `[ ]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
|
### `[x]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
|
||||||
|
|
||||||
3.11.174 from `cdnjs`, no SRI, and CSP allows the whole host for `script-src` **and**
|
Vendored rather than integrity-pinned, so the Site no longer depends on a third
|
||||||
`worker-src`. Vendor the asset or pin `integrity` and narrow the CSP to the exact path.
|
party being reachable and honest when a customer opens it. Both files are served
|
||||||
|
from this origin and their provenance is recorded in `local/static/vendor/README.md`,
|
||||||
|
verified against the SRI digests cdnjs publishes for 3.11.174.
|
||||||
|
|
||||||
|
`cdnjs.cloudflare.com` is gone from `script-src`, `worker-src` and `connect-src` in
|
||||||
|
both gateway templates: scripts and workers are now `'self'` plus `blob:` for the
|
||||||
|
worker the Site builds itself.
|
||||||
|
|
||||||
|
Verified in a browser against the running stack: pdf.js loads from `/vendor/`,
|
||||||
|
the blob worker starts, and a real 7-page PDF parses with no CSP violation. Both
|
||||||
|
browser suites and the full integration suite pass.
|
||||||
|
|
||||||
|
**Still open: the version.** 3.11.174 is old. GHSA-wgrm-67xf-hhpq is mitigated —
|
||||||
|
`dtf-site.html` already passes `isEvalSupported: false`, which is the documented
|
||||||
|
workaround — but staying on it indefinitely is not a posture. Upgrading is an API
|
||||||
|
change rather than a file swap and needs its own browser testing, so it is
|
||||||
|
deliberately not bundled here.
|
||||||
|
|
||||||
### `[ ]` 2.8 — Single shared operator credential `(F12)`
|
### `[ ]` 2.8 — Single shared operator credential `(F12)`
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ The DTF application is one Portainer-owned Docker Swarm stack. Gitea builds,
|
|||||||
tests, scans, and publishes the two application images, then calls the stack's
|
tests, scans, and publishes the two application images, then calls the stack's
|
||||||
Portainer webhook. Start with the short operator guide in `../PORTAINER.md`.
|
Portainer webhook. Start with the short operator guide in `../PORTAINER.md`.
|
||||||
|
|
||||||
- `stack.yaml` — the single Portainer stack.
|
- The deployed stack is the repository's `docker-compose.yml`, not a file here.
|
||||||
- `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images.
|
- `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images.
|
||||||
- `portainer.env.example` — non-secret Portainer variables.
|
- `portainer.env.example` — non-secret Portainer variables.
|
||||||
- `production_preflight.py` — fail-closed application/configuration validator.
|
- `production_preflight.py` — fail-closed application/configuration validator.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ server {
|
|||||||
add_header Referrer-Policy no-referrer always;
|
add_header Referrer-Policy no-referrer always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ https://cdnjs.cloudflare.com; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||||
|
|
||||||
location = /health { access_log off; return 200 'ok'; }
|
location = /health { access_log off; return 200 'ok'; }
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
x-app-environment: &app-environment
|
|
||||||
APP_ENV: production
|
|
||||||
DATABASE_URL_FILE: /run/secrets/database_url
|
|
||||||
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
|
||||||
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
|
|
||||||
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
|
|
||||||
AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id
|
|
||||||
AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key
|
|
||||||
AWS_DEFAULT_REGION: auto
|
|
||||||
# The Kanban authenticates by email; the runtime reads OPERATOR_EMAIL.
|
|
||||||
OPERATOR_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL}
|
|
||||||
OPERATOR_PASSWORD_FILE: /run/secrets/operator_password
|
|
||||||
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER}
|
|
||||||
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER}
|
|
||||||
TINY_ADAPTER: ${TINY_ADAPTER:?set TINY_ADAPTER}
|
|
||||||
WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:?set WHATSAPP_ADAPTER}
|
|
||||||
STORAGE_ADAPTER: s3-r2
|
|
||||||
PAYMENT_TOKEN_FILE: /run/secrets/payment_token
|
|
||||||
PAYMENT_WEBHOOK_SECRET_FILE: /run/secrets/payment_webhook_secret
|
|
||||||
TINY_TOKEN_FILE: /run/secrets/tiny_token
|
|
||||||
WHATSAPP_TOKEN_FILE: /run/secrets/whatsapp_token
|
|
||||||
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
|
|
||||||
ALLOWED_HOSTS: ${PUBLIC_HOST:?set PUBLIC_HOST},${KANBAN_HOST:?set KANBAN_HOST}
|
|
||||||
ALLOWED_ORIGINS: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN},https://${KANBAN_HOST:?set KANBAN_HOST}
|
|
||||||
COOKIE_SECURE: "true"
|
|
||||||
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
|
|
||||||
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
|
|
||||||
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:?set STORAGE_QUOTA_BYTES}
|
|
||||||
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:?set OWNER_UPLOAD_QUOTA_BYTES}
|
|
||||||
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
|
|
||||||
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
|
|
||||||
|
|
||||||
x-app-secrets: &app-secrets
|
|
||||||
- database_url
|
|
||||||
- r2_access_key_id
|
|
||||||
- r2_secret_access_key
|
|
||||||
- operator_password
|
|
||||||
- payment_token
|
|
||||||
- payment_webhook_secret
|
|
||||||
- tiny_token
|
|
||||||
- whatsapp_token
|
|
||||||
|
|
||||||
x-rolling: &rolling
|
|
||||||
update_config:
|
|
||||||
parallelism: 1
|
|
||||||
delay: 10s
|
|
||||||
order: start-first
|
|
||||||
failure_action: rollback
|
|
||||||
monitor: 45s
|
|
||||||
rollback_config:
|
|
||||||
parallelism: 1
|
|
||||||
delay: 5s
|
|
||||||
order: start-first
|
|
||||||
failure_action: pause
|
|
||||||
monitor: 45s
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
delay: 5s
|
|
||||||
max_attempts: 5
|
|
||||||
window: 60s
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: ${POSTGRES_IMAGE:?set POSTGRES_IMAGE}
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER}
|
|
||||||
POSTGRES_PASSWORD_FILE: /run/secrets/db_admin_password
|
|
||||||
secrets: [db_admin_password]
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
networks: [backend]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 20s
|
|
||||||
stop_grace_period: 60s
|
|
||||||
deploy:
|
|
||||||
replicas: 1
|
|
||||||
placement:
|
|
||||||
constraints: [node.labels.dtf_database == true]
|
|
||||||
update_config:
|
|
||||||
parallelism: 1
|
|
||||||
order: stop-first
|
|
||||||
failure_action: rollback
|
|
||||||
monitor: 60s
|
|
||||||
rollback_config:
|
|
||||||
parallelism: 1
|
|
||||||
order: stop-first
|
|
||||||
failure_action: pause
|
|
||||||
monitor: 60s
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
delay: 10s
|
|
||||||
max_attempts: 5
|
|
||||||
window: 120s
|
|
||||||
resources:
|
|
||||||
limits: {cpus: "2.0", memory: 4G}
|
|
||||||
reservations: {cpus: "0.5", memory: 1G}
|
|
||||||
|
|
||||||
db-init:
|
|
||||||
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
|
|
||||||
command: python -m local.bootstrap
|
|
||||||
environment:
|
|
||||||
APP_ENV: production
|
|
||||||
DATABASE_ADMIN_URL_FILE: /run/secrets/database_admin_url
|
|
||||||
APP_DB_USER: ${APP_DB_USER:?set APP_DB_USER}
|
|
||||||
APP_DB_PASSWORD_FILE: /run/secrets/app_db_password
|
|
||||||
secrets: [database_admin_url, app_db_password]
|
|
||||||
networks: [backend]
|
|
||||||
deploy:
|
|
||||||
replicas: 1
|
|
||||||
restart_policy: {condition: none}
|
|
||||||
placement:
|
|
||||||
constraints: [node.platform.os == linux]
|
|
||||||
resources:
|
|
||||||
limits: {cpus: "0.5", memory: 512M}
|
|
||||||
|
|
||||||
scanner:
|
|
||||||
image: ${CLAMAV_IMAGE:?set CLAMAV_IMAGE}
|
|
||||||
user: "100:101"
|
|
||||||
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
|
|
||||||
configs:
|
|
||||||
- source: clamd_config
|
|
||||||
target: /etc/clamav/clamd.conf
|
|
||||||
mode: 0444
|
|
||||||
networks: [backend]
|
|
||||||
read_only: true
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: [no-new-privileges:true]
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:uid=100,gid=101,mode=0750
|
|
||||||
- /run/clamav:uid=100,gid=101,mode=0750
|
|
||||||
- /var/log/clamav:uid=100,gid=101,mode=0750
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
start_period: 90s
|
|
||||||
deploy:
|
|
||||||
replicas: 1
|
|
||||||
restart_policy: {condition: on-failure, delay: 10s}
|
|
||||||
resources:
|
|
||||||
limits: {cpus: "2.0", memory: 3G}
|
|
||||||
reservations: {cpus: "0.5", memory: 1G}
|
|
||||||
|
|
||||||
api:
|
|
||||||
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
|
|
||||||
environment: *app-environment
|
|
||||||
secrets: *app-secrets
|
|
||||||
networks: [backend, egress]
|
|
||||||
read_only: true
|
|
||||||
tmpfs: [/tmp]
|
|
||||||
init: true
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: [no-new-privileges:true]
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD-SHELL
|
|
||||||
- >-
|
|
||||||
python -c "import os,urllib.request; r=urllib.request.Request('http://localhost:8000/health',headers={'Host':os.environ['PUBLIC_HOST']}); urllib.request.urlopen(r,timeout=3)"
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 30s
|
|
||||||
stop_grace_period: 30s
|
|
||||||
deploy:
|
|
||||||
<<: *rolling
|
|
||||||
replicas: 2
|
|
||||||
resources:
|
|
||||||
limits: {cpus: "1.0", memory: 1G}
|
|
||||||
reservations: {cpus: "0.25", memory: 256M}
|
|
||||||
|
|
||||||
worker:
|
|
||||||
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
|
|
||||||
command: python -m local.worker
|
|
||||||
environment:
|
|
||||||
<<: *app-environment
|
|
||||||
CLAMD_HOST: scanner
|
|
||||||
secrets: *app-secrets
|
|
||||||
networks: [backend, egress]
|
|
||||||
read_only: true
|
|
||||||
tmpfs: [/tmp]
|
|
||||||
init: true
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: [no-new-privileges:true]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 90s
|
|
||||||
stop_grace_period: 60s
|
|
||||||
deploy:
|
|
||||||
<<: *rolling
|
|
||||||
replicas: 1
|
|
||||||
update_config:
|
|
||||||
parallelism: 1
|
|
||||||
order: stop-first
|
|
||||||
failure_action: rollback
|
|
||||||
monitor: 60s
|
|
||||||
rollback_config:
|
|
||||||
parallelism: 1
|
|
||||||
order: stop-first
|
|
||||||
failure_action: pause
|
|
||||||
monitor: 60s
|
|
||||||
resources:
|
|
||||||
limits: {cpus: "1.5", memory: 2G}
|
|
||||||
reservations: {cpus: "0.25", memory: 512M}
|
|
||||||
|
|
||||||
site:
|
|
||||||
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
|
|
||||||
environment:
|
|
||||||
WEB_INDEX: index.html
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
|
|
||||||
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
|
|
||||||
networks: [backend]
|
|
||||||
ports:
|
|
||||||
- target: 8080
|
|
||||||
published: ${SITE_PORT:-8080}
|
|
||||||
protocol: tcp
|
|
||||||
mode: ingress
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:uid=101,gid=101,mode=0750
|
|
||||||
- /var/cache/nginx:uid=101,gid=101,mode=0750
|
|
||||||
- /var/run:uid=101,gid=101,mode=0750
|
|
||||||
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: [no-new-privileges:true]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 15s
|
|
||||||
deploy:
|
|
||||||
<<: *rolling
|
|
||||||
replicas: 2
|
|
||||||
resources:
|
|
||||||
limits: {cpus: "0.5", memory: 256M}
|
|
||||||
reservations: {cpus: "0.1", memory: 64M}
|
|
||||||
|
|
||||||
kanban:
|
|
||||||
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
|
|
||||||
environment:
|
|
||||||
WEB_INDEX: kanban.html
|
|
||||||
PUBLIC_HOST: ${KANBAN_HOST:?set KANBAN_HOST}
|
|
||||||
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
|
|
||||||
networks: [backend]
|
|
||||||
ports:
|
|
||||||
- target: 8080
|
|
||||||
published: ${KANBAN_PORT:-8081}
|
|
||||||
protocol: tcp
|
|
||||||
mode: ingress
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:uid=101,gid=101,mode=0750
|
|
||||||
- /var/cache/nginx:uid=101,gid=101,mode=0750
|
|
||||||
- /var/run:uid=101,gid=101,mode=0750
|
|
||||||
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: [no-new-privileges:true]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 15s
|
|
||||||
deploy:
|
|
||||||
<<: *rolling
|
|
||||||
replicas: 1
|
|
||||||
resources:
|
|
||||||
limits: {cpus: "0.5", memory: 256M}
|
|
||||||
reservations: {cpus: "0.1", memory: 64M}
|
|
||||||
|
|
||||||
configs:
|
|
||||||
clamd_config:
|
|
||||||
file: ../local/clamd.conf
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
database_url: {external: true, name: "${DATABASE_URL_SECRET:?set DATABASE_URL_SECRET}"}
|
|
||||||
database_admin_url: {external: true, name: "${DATABASE_ADMIN_URL_SECRET:?set DATABASE_ADMIN_URL_SECRET}"}
|
|
||||||
db_admin_password: {external: true, name: "${DB_ADMIN_PASSWORD_SECRET:?set DB_ADMIN_PASSWORD_SECRET}"}
|
|
||||||
app_db_password: {external: true, name: "${APP_DB_PASSWORD_SECRET:?set APP_DB_PASSWORD_SECRET}"}
|
|
||||||
r2_access_key_id: {external: true, name: "${R2_ACCESS_KEY_ID_SECRET:?set R2_ACCESS_KEY_ID_SECRET}"}
|
|
||||||
r2_secret_access_key: {external: true, name: "${R2_SECRET_ACCESS_KEY_SECRET:?set R2_SECRET_ACCESS_KEY_SECRET}"}
|
|
||||||
operator_password: {external: true, name: "${OPERATOR_PASSWORD_SECRET:?set OPERATOR_PASSWORD_SECRET}"}
|
|
||||||
payment_token: {external: true, name: "${PAYMENT_TOKEN_SECRET:?set PAYMENT_TOKEN_SECRET}"}
|
|
||||||
payment_webhook_secret: {external: true, name: "${PAYMENT_WEBHOOK_SECRET:?set PAYMENT_WEBHOOK_SECRET}"}
|
|
||||||
tiny_token: {external: true, name: "${TINY_TOKEN_SECRET:?set TINY_TOKEN_SECRET}"}
|
|
||||||
whatsapp_token: {external: true, name: "${WHATSAPP_TOKEN_SECRET:?set WHATSAPP_TOKEN_SECRET}"}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
||||||
external: true
|
|
||||||
name: ${POSTGRES_VOLUME:?set POSTGRES_VOLUME}
|
|
||||||
|
|
||||||
networks:
|
|
||||||
backend:
|
|
||||||
driver: overlay
|
|
||||||
internal: true
|
|
||||||
egress:
|
|
||||||
driver: overlay
|
|
||||||
@@ -1780,8 +1780,11 @@ function analisarFolha(img, larguraCm){
|
|||||||
|
|
||||||
// ── PDF também é conferido: rasteriza a página e mede o DPI das imagens de dentro.
|
// ── PDF também é conferido: rasteriza a página e mede o DPI das imagens de dentro.
|
||||||
// Arte vetorial não tem resolução — nesses casos a nota é máxima, e isso é correto.
|
// Arte vetorial não tem resolução — nesses casos a nota é máxima, e isso é correto.
|
||||||
const PDFJS_URL='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
// Served from this origin, not a CDN: the Site keeps working when a third party
|
||||||
const PDFJS_WORKER='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
// does not, and the CSP can name only 'self' for scripts and workers. Provenance
|
||||||
|
// and hashes are recorded in local/static/vendor/README.md.
|
||||||
|
const PDFJS_URL='/vendor/pdf.min.js';
|
||||||
|
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
|
||||||
let pdfLibP=null, temWorker=null;
|
let pdfLibP=null, temWorker=null;
|
||||||
// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de
|
// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de
|
||||||
// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra
|
// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ server {
|
|||||||
add_header Referrer-Policy no-referrer always;
|
add_header Referrer-Policy no-referrer always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ https://cdnjs.cloudflare.com; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||||
location = /health { access_log off; return 200 'ok'; }
|
location = /health { access_log off; return 200 'ok'; }
|
||||||
location /api/ {
|
location /api/ {
|
||||||
limit_req zone=api_limit burst=100 nodelay;
|
limit_req zone=api_limit burst=100 nodelay;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Resolve Docker secret files into the environment before configuration is read.
|
"""Resolve Docker secret files into the environment before configuration is read.
|
||||||
|
|
||||||
Swarm mounts each secret as a file and the stack passes its path as `<NAME>_FILE`.
|
Swarm mounts each secret as a file and the stack passes its path as `<NAME>_FILE`.
|
||||||
Nothing read `_FILE` settings, so `deploy/stack.yaml` could not boot: the runtime
|
The deployed `docker-compose.yml` passes credentials as plain environment
|
||||||
looked for `DATABASE_URL`, `AWS_ACCESS_KEY_ID` and `OPERATOR_PASSWORD` while the
|
variables, so this module is inert there. It exists so a stack can supply them as
|
||||||
stack supplied only the `_FILE` form.
|
Docker secrets instead without any code change; see `ROADMAP.md` 2.12.
|
||||||
|
|
||||||
Call `load()` in every entrypoint before any configuration is read.
|
Call `load()` in every entrypoint before any configuration is read.
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ secrets` elsewhere in the package to the standard library, not to this file.
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# The settings production supplies as secret files. Any other `*_FILE` variable is
|
# The settings a stack may supply as secret files. Any other `*_FILE` variable is
|
||||||
# resolved the same way; this list documents the contract and is what the release
|
# resolved the same way; this list documents the contract and is what the release
|
||||||
# gate checks against, so keep it in step with `deploy/stack.yaml`.
|
# gate checks against.
|
||||||
SECRET_FILE_SETTINGS = (
|
SECRET_FILE_SETTINGS = (
|
||||||
'DATABASE_URL',
|
'DATABASE_URL',
|
||||||
'DATABASE_ADMIN_URL',
|
'DATABASE_ADMIN_URL',
|
||||||
|
|||||||
33
local/static/vendor/README.md
vendored
Normal file
33
local/static/vendor/README.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Vendored third-party assets
|
||||||
|
|
||||||
|
Served from this repository rather than a CDN, so the Site does not depend on a
|
||||||
|
third party being reachable and honest at the moment a customer opens it, and so
|
||||||
|
the Content-Security-Policy can name only `'self'` for scripts and workers.
|
||||||
|
|
||||||
|
## pdf.js 3.11.174
|
||||||
|
|
||||||
|
Used by the by-metre flow to measure and rasterise a PDF sheet in the browser.
|
||||||
|
|
||||||
|
| File | SHA-256 |
|
||||||
|
|---|---|
|
||||||
|
| `pdf.min.js` | `5b5799e6f8c680663207ac5b42ee14eed2a406fa7af48f50c154f0c0b1566946` |
|
||||||
|
| `pdf.worker.min.js` | `feabdf309770ed24bba31a5467836cdc8cf639c705af27d52b585b041bb8527b` |
|
||||||
|
|
||||||
|
Downloaded from `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/` and
|
||||||
|
verified against the SRI digests cdnjs publishes for that release:
|
||||||
|
|
||||||
|
```
|
||||||
|
pdf.min.js sha512-q+4liFwdPC/bNdhUpZx6aXDx/h77yEQtn4I1slHydcbZK34nLaR3cAeYSJshoxIOq3mjEf7xJE8YWIUHMn+oCQ==
|
||||||
|
pdf.worker.min.js sha512-BbrZ76UNZq5BhH7LL7pn9A4TKQpQeNCHOo65/akfelcIBbcVvYWOFQKPXIrykE3qZxYjmDX573oa4Ywsc7rpTw==
|
||||||
|
```
|
||||||
|
|
||||||
|
To verify or refresh, compare against that API before replacing anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://api.cdnjs.com/libraries/pdf.js/<version>?fields=sri"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Version note.** 3.11.174 is old. It is affected by GHSA-wgrm-67xf-hhpq, whose
|
||||||
|
documented workaround is `isEvalSupported: false`; `dtf-site.html` already passes
|
||||||
|
that, so the known path is closed. Upgrading is worthwhile but is an API change,
|
||||||
|
not a file swap, and belongs with its own browser testing — see `ROADMAP.md` 2.7.
|
||||||
22
local/static/vendor/pdf.min.js
vendored
Normal file
22
local/static/vendor/pdf.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
local/static/vendor/pdf.worker.min.js
vendored
Normal file
22
local/static/vendor/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user