feat/Update_Controllers_to_Allow_for_Premium_Membership

This commit is contained in:
cesnimda
2026-08-03 09:17:28 +02:00
parent de937d25dc
commit c3f4a57195
187 changed files with 26062 additions and 991 deletions
+56 -2
View File
@@ -52,6 +52,7 @@ AUTH_ADMIN_EMAIL=you@example.com
AUTH_ADMIN_PASSWORD=replace_with_strong_password
AUTH_REQUIRE_EMAIL_VERIFICATION=true
APP_PUBLIC_BASE_URL=https://your-domain.example
WEB_PROXY_SUBNET=172.31.250.0/29
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PRICE_PREMIUM=price_...
STRIPE_WEBHOOK_SECRET=whsec_...
@@ -89,10 +90,23 @@ If this app is going to be a real production service on Ubuntu:
- MariaDB is still a reasonable option if preferred
## Deployment flow
Production automation always selects `docker-compose.yml` explicitly. Local development must add
`docker-compose.dev.yml`; never add that file to a production command. The base configuration has no
host bindings for frontend, backend, ai-service, or bundled Ollama.
The external Traefik configuration is operator-owned and is not stored here. Before deployment it
must route only the exact host from `APP_PUBLIC_BASE_URL` to frontend port 80 on
`jobtracker_shared`, terminate TLS, replace `X-Forwarded-For` and `X-Forwarded-Proto=https`, and
expose no direct application or Ollama host ports.
Nginx independently rejects non-canonical Hosts except `/health`, forwards Traefik's sanitized
single-hop values, and reaches the backend only over `WEB_PROXY_SUBNET`. The backend fails startup
if forwarded-header trust is enabled without a valid known CIDR.
1. push to `main`
2. Gitea Actions runs tests
3. if green, workflow uploads repo to server
4. `deploy/deploy.sh` links `/opt/job-tracker/shared/.env` into the repo checkout, then runs `docker compose build && docker compose up -d`
4. `deploy/deploy.sh` links `/opt/job-tracker/shared/.env` into the repo checkout, then explicitly runs `docker compose -f docker-compose.yml build` and `up -d`
5. if `OLLAMA_MODEL` is set, the deploy script waits for Ollama, pulls the configured model if missing, then restarts `ai-service` so hybrid CV classification can use it
6. workflow checks service status after deployment
@@ -141,7 +155,47 @@ or replaced. A missing variable aborts the deploy while the running stack is sti
| `JOBTRACKER_CONNECTION_STRING` | When provider is `mariadb`/`mysql` | Without it there is no way to dump the database |
| `AI_SERVICE_TOKEN` | Always | `docker-compose.yml` declares it with `:?`; missing it kills the stack *after* the images are built |
| `AUTH_JWT_KEY` | Always | Compose sets `Auth__Require=true`, and the backend throws at startup on a blank key — after the containers have been replaced |
| `APP_PUBLIC_BASE_URL` | Optional | If unset the post-deploy public smoke check is skipped, and the script says so rather than skipping silently |
| `APP_PUBLIC_BASE_URL` | Always | Canonical HTTPS origin for links, OAuth callbacks, billing redirects, secure cookies, Host validation, and the public smoke check |
| `AUTH_MICROSOFT_TENANT` | When `AUTH_MICROSOFT_CLIENT_ID` is set | Exact Microsoft application sign-in account mode; distinct from the Graph mailbox tenant |
| `WEB_PROXY_SUBNET` | Always | Dedicated nginx-to-backend CIDR trusted for exactly one forwarded hop; must not overlap another Docker network |
### Microsoft sign-in migration gate
Before enabling `AUTH_MICROSOFT_CLIENT_ID` with the canonical identity release, take the normal
backup and record counts only. Do not print subjects or email addresses:
```sql
SELECT COUNT(*) AS legacy_links
FROM AspNetUsers
WHERE MicrosoftSubject IS NOT NULL OR MicrosoftEmail IS NOT NULL;
SELECT COUNT(*) AS legacy_without_alternate_credential
FROM AspNetUsers
WHERE (MicrosoftSubject IS NOT NULL OR MicrosoftEmail IS NOT NULL)
AND PasswordHash IS NULL
AND GoogleSubject IS NULL;
SELECT COUNT(*) AS duplicate_legacy_subject_groups
FROM (
SELECT MicrosoftSubject
FROM AspNetUsers
WHERE MicrosoftSubject IS NOT NULL
GROUP BY MicrosoftSubject HAVING COUNT(*) > 1
) duplicate_subjects;
SELECT COUNT(*) AS duplicate_legacy_email_groups
FROM (
SELECT MicrosoftEmail
FROM AspNetUsers
WHERE MicrosoftEmail IS NOT NULL
GROUP BY MicrosoftEmail HAVING COUNT(*) > 1
) duplicate_emails;
```
Apply `20260802212509_AddCanonicalMicrosoftIdentity` before deploying code that queries the two new
columns. The migration does not backfill legacy rows and adds a unique nullable composite index.
Keep Microsoft sign-in disabled if the inventory or migration fails. Roll back the application
binary while leaving the additive columns in place; never roll back to email auto-linking.
`DATABASE_PROVIDER` deliberately has **no default**. An unset value used to mean "sqlite"; it now
means "stop and tell me".
+72 -39
View File
@@ -67,7 +67,7 @@ export APP_BUILD_STAMP="${APP_BUILD_STAMP:-unknown}"
export DEPLOY_BUILD_AI_SERVICE="${DEPLOY_BUILD_AI_SERVICE:-false}"
compose() {
docker compose "$@"
docker compose -f docker-compose.yml "$@"
}
# ---------------------------------------------------------------------------
@@ -92,7 +92,8 @@ require_var() {
}
validate_deploy_config() {
local failed=0
local failed=0 octet subnet_address microsoft_tenant
local -a subnet_octets=()
# Deliberately no default. Guessing this wrong means backing up the wrong
# database and reporting success — the exact failure this block exists to stop.
@@ -125,8 +126,42 @@ validate_deploy_config() {
require_var AUTH_JWT_KEY \
"JWT signing key. With Auth__Require=true the backend refuses to start without it." || failed=1
if [ -z "${APP_PUBLIC_BASE_URL:-}" ]; then
echo "Note: APP_PUBLIC_BASE_URL is not set — the post-deploy public smoke check will be skipped."
if [ -n "${AUTH_MICROSOFT_CLIENT_ID:-}" ]; then
require_var AUTH_MICROSOFT_TENANT \
"Microsoft sign-in tenant policy: tenant GUID, organizations, consumers, or common." || failed=1
microsoft_tenant="$(printf '%s' "${AUTH_MICROSOFT_TENANT:-}" | tr '[:upper:]' '[:lower:]')"
if [ -n "$microsoft_tenant" ] \
&& [[ "$microsoft_tenant" != "common" ]] \
&& [[ "$microsoft_tenant" != "organizations" ]] \
&& [[ "$microsoft_tenant" != "consumers" ]] \
&& [[ ! "$microsoft_tenant" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
echo "AUTH_MICROSOFT_TENANT is invalid."
failed=1
fi
fi
require_var APP_PUBLIC_BASE_URL \
"Canonical public HTTPS origin, for example https://jobs.example.com." || failed=1
if [ -n "${APP_PUBLIC_BASE_URL:-}" ] && [[ ! "$APP_PUBLIC_BASE_URL" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$ ]]; then
echo "APP_PUBLIC_BASE_URL must be one HTTPS origin without credentials, a path, query, or fragment."
failed=1
fi
require_var WEB_PROXY_SUBNET \
"Dedicated nginx-to-backend CIDR, for example 172.31.250.0/29; check it does not overlap another Docker network." || failed=1
if [ -n "${WEB_PROXY_SUBNET:-}" ] && [[ ! "$WEB_PROXY_SUBNET" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; then
echo "WEB_PROXY_SUBNET must be an IPv4 CIDR."
failed=1
elif [ -n "${WEB_PROXY_SUBNET:-}" ]; then
subnet_address="${WEB_PROXY_SUBNET%/*}"
IFS='.' read -r -a subnet_octets <<< "$subnet_address"
for octet in "${subnet_octets[@]}"; do
if ((10#$octet > 255)); then
echo "WEB_PROXY_SUBNET contains an invalid IPv4 octet."
failed=1
break
fi
done
fi
if [ "$failed" -ne 0 ]; then
@@ -418,44 +453,42 @@ if [ "$ai_status" != "running" ]; then
compose logs --tail=200 ai-service || true
fi
if [ -n "${APP_PUBLIC_BASE_URL:-}" ]; then
public_base="${APP_PUBLIC_BASE_URL%/}"
auth_config_body_file="$(mktemp)"
auth_config_headers_file="$(mktemp)"
cleanup_public_check() {
rm -f "$auth_config_body_file" "$auth_config_headers_file"
}
trap cleanup_public_check EXIT
public_base="${APP_PUBLIC_BASE_URL%/}"
auth_config_body_file="$(mktemp)"
auth_config_headers_file="$(mktemp)"
cleanup_public_check() {
rm -f "$auth_config_body_file" "$auth_config_headers_file"
}
trap cleanup_public_check EXIT
echo "Running public smoke check against ${public_base}"
if ! curl -fsS "${public_base}/" >/dev/null; then
echo "Public frontend check failed for ${public_base}/"
exit 1
fi
if ! curl -fsS -D "$auth_config_headers_file" -o "$auth_config_body_file" "${public_base}/api/auth/config"; then
echo "Public API smoke check failed for ${public_base}/api/auth/config"
exit 1
fi
content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/ {print $2}' "$auth_config_headers_file" | tr -d '\r' | tail -n 1)"
if [[ "$content_type" != application/json* ]]; then
echo "Public API smoke check returned unexpected content type: ${content_type:-missing}"
echo "First bytes of response:"
head -c 200 "$auth_config_body_file" || true
exit 1
fi
if ! grep -q 'requireAuth' "$auth_config_body_file"; then
echo "Public API smoke check returned JSON without requireAuth."
cat "$auth_config_body_file"
exit 1
fi
trap - EXIT
cleanup_public_check
echo "Running public smoke check against ${public_base}"
if ! curl -fsS "${public_base}/" >/dev/null; then
echo "Public frontend check failed for ${public_base}/"
exit 1
fi
if ! curl -fsS -D "$auth_config_headers_file" -o "$auth_config_body_file" "${public_base}/api/auth/config"; then
echo "Public API smoke check failed for ${public_base}/api/auth/config"
exit 1
fi
content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/ {print $2}' "$auth_config_headers_file" | tr -d '\r' | tail -n 1)"
if [[ "$content_type" != application/json* ]]; then
echo "Public API smoke check returned unexpected content type: ${content_type:-missing}"
echo "First bytes of response:"
head -c 200 "$auth_config_body_file" || true
exit 1
fi
if ! grep -q 'requireAuth' "$auth_config_body_file"; then
echo "Public API smoke check returned JSON without requireAuth."
cat "$auth_config_body_file"
exit 1
fi
trap - EXIT
cleanup_public_check
# Clean up old legacy container name if it still exists from pre-rename deployments.
docker rm -f app-summarizer-1 2>/dev/null || true
+4 -2
View File
@@ -49,8 +49,10 @@ Verified by reading `deploy/deploy.sh` and `JobTrackerApi/Program.cs`:
- `AI_SERVICE_TOKEN` — compose refuses to start without it
- `AUTH_JWT_KEY` — with `Auth__Require=true`, a blank key **throws at startup** (this is good;
it fails loud rather than silently invalidating every session on restart)
- `APP_PUBLIC_BASE_URL`optional; without it the post-deploy public smoke check is skipped,
and the script prints that it is skipping
- `APP_PUBLIC_BASE_URL`required canonical HTTPS origin with no path, query, fragment or
credentials; it controls generated links, OAuth callbacks, secure cookies and Host validation
- `WEB_PROXY_SUBNET` — required dedicated IPv4 CIDR for nginx-to-backend traffic; confirm it
does not overlap any existing Docker network before deployment
- `AUTH_ADMIN_EMAIL` / `AUTH_ADMIN_PASSWORD` only if you want admin seeding on this boot
- [ ] **Connection string host resolves from inside the container.** `Server=127.0.0.1` means *the
backend container*, not the host — this bit me during validation. Use the host's LAN address, a