Add Ping backend

.NET 10 solution for Ping, a Discord style chat app: Domain, Application
and Infrastructure projects plus the Api host. SignalR for chat and
presence, LiveKit for call tokens, EF Core on Postgres, S3 compatible
storage for attachments, and OIDC auth shared with the Musify Zitadel
instance. Includes the Compose stack for local development.
This commit is contained in:
2026-09-21 19:32:55 +02:00
commit 0623875dfa
126 changed files with 5048 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# Development configuration for deploy/compose.yml + compose.dev.yml.
# Copy to deploy/.env and adjust. Not versioned.
#
# cp deploy/.env.example deploy/.env
DOTNET_ENVIRONMENT=Development
# --- Public URLs -------------------------------------------------------------
PUBLIC_WEB_URL=http://localhost:3100
PUBLIC_API_URL=http://localhost:5211
# Ping reuses the Zitadel and SeaweedFS instances already running for Musify instead of
# spinning up its own, so this must match Musify's PUBLIC_AUTH_URL exactly.
PUBLIC_AUTH_URL=http://host.docker.internal:8080
# --- PostgreSQL (Ping's own database — Zitadel keeps using Musify's) --------
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=ping_db
POSTGRES_HOST_PORT=59010
# --- Zitadel (shared with Musify) --------------------------------------------
# zitadel-init needs Musify's admin service-account PAT to create Ping's own project/apps
# in that same Zitadel instance. Point this at Musify's deploy/zitadel/.output folder —
# adjust if your Musify checkout lives somewhere else relative to this one.
ZITADEL_ADMIN_PAT_DIR=../../../Musify/MusifyM/deploy/zitadel/.output
ZITADEL_DEV_MODE=true
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills these in
# automatically on `docker compose up -d`.
AUTH_CLIENT_ID=
WEB_CLIENT_ID=
# --- SeaweedFS (S3, shared with Musify) --------------------------------------
# Same SeaweedFS instance as Musify, so these must be the REAL values from Musify's own
# deploy/.env (S3_ACCESS_KEY / S3_SECRET_KEY) — not the placeholders below.
S3_ACCESS_KEY=admin_access_key
S3_SECRET_KEY=admin_secret_key
S3_BUCKET=ping-storage
S3_PUBLIC_URL=http://host.docker.internal:8333
S3_USE_HTTP=true
# --- LiveKit (voice/video SFU) ------------------------------------------------
# Overrides --dev's built-in "devkey/secret" pair (see compose.yml's LIVEKIT_KEYS): the
# LiveKit .NET server SDK rejects secrets shorter than 256 bits. Generate your own with:
# $b = [byte[]]::new(32); [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); [Convert]::ToBase64String($b)
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=9U1eDtZx9eQrSWern4UcWa3lYIrfvrQqExLlkUXpP+o=
# --- Misc --------------------------------------------------------------------
AUTH_REQUIRE_HTTPS=false
+29
View File
@@ -0,0 +1,29 @@
# Ping — deploy
Reuses the Zitadel and SeaweedFS containers already running for Musify instead of starting new
ones. Musify's stack must be up first.
```bash
cp deploy/.env.example deploy/.env
# edit deploy/.env:
# - ZITADEL_ADMIN_PAT_DIR must point at Musify's deploy/zitadel/.output folder
# - S3_ACCESS_KEY / S3_SECRET_KEY must match the REAL values in Musify's own deploy/.env
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
```
This starts Postgres, LiveKit, and the one-shot jobs (EF Core migrations, the Zitadel
project/apps for Ping, the `ping-storage` S3 bucket) — the usual dev loop runs the API and
frontend from the IDE against these.
To also build and run the API/frontend in Docker instead:
```bash
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
```
By then `zitadel-init` has already written `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` into `deploy/.env`.
Ports (dev): API `5211`, web `3100`, Postgres `59010`, LiveKit `7880` (signaling) / `7881` (RTC
TCP) / `50000-50100/udp` (media). Zitadel (`8080`) and the SeaweedFS S3 API (`8333`) come from
Musify's own stack.
+39
View File
@@ -0,0 +1,39 @@
name: ping-dev
# Development overlay: publishes the infrastructure ports to the host so the .NET apps and
# the web frontend can run from the IDE.
#
# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
x-host-alias: &host-alias
- "host.docker.internal:host-gateway"
services:
postgres:
ports:
- "${POSTGRES_HOST_PORT}:5432"
livekit:
ports:
- "7880:7880" # HTTP / WebSocket signaling
- "7881:7881" # RTC TCP fallback
- "50000-50100:50000-50100/udp" # WebRTC media
zitadel-init:
extra_hosts: *host-alias
volumes:
- ./.env:/workspace/.env
seaweedfs-init:
extra_hosts: *host-alias
api:
ports:
- "5211:5211"
extra_hosts: *host-alias
web:
ports:
- "3100:3100"
extra_hosts: *host-alias
+271
View File
@@ -0,0 +1,271 @@
# Ping — base stack, shared by development and production.
#
# This intentionally does NOT run Zitadel or SeaweedFS: Ping reuses the same instances
# already running for Musify (see deploy/.env.example — ZITADEL_ADMIN_PAT_DIR / S3_*).
# Bring Musify's stack up first, then this one.
#
# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
#
# The `api` and `web` services sit behind the `apps` profile, so a plain `up` starts
# Postgres, LiveKit and the one-shot bootstrap jobs (migrations, Zitadel project/apps,
# SeaweedFS bucket) and stops there — the usual dev setup, with the apps running from the
# IDE. Add `--profile apps up -d --build` as a SECOND command to also build and run them in
# Docker: by then zitadel-init has already written the OIDC client ids into the env file.
x-app-depends: &app-depends
postgres:
condition: service_healthy
x-backend-env: &backend-env
Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
# The public S3 URL, not the internal one: the same address is used to sign the
# presigned upload/download URLs the browser has to be able to reach.
InfrastructureStorage__Address: ${S3_PUBLIC_URL}
InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY}
InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY}
InfrastructureStorage__ForcePathStyle: "true"
InfrastructureStorage__UseHttp: ${S3_USE_HTTP}
ApplicationStorage__Bucket: ${S3_BUCKET}
services:
# --- Infrastructure -------------------------------------------------------
postgres:
image: postgres:17-alpine
container_name: ping-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
# Voice/video SFU. --dev keeps it single-node with in-memory state (fine for local
# development), but the key pair is overridden via LIVEKIT_KEYS: the LiveKit .NET server SDK
# rejects secrets shorter than 256 bits, and --dev's built-in "devkey/secret" pair is too short.
livekit:
image: livekit/livekit-server:latest
container_name: ping-livekit
command: --dev --bind 0.0.0.0
environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
# Node IP is "automatically determined" by default, which inside a container means the
# Docker bridge network's internal address (e.g. 172.x.x.x) — unreachable from the
# browser, so ICE/media never connects even though the WebSocket signaling (which goes
# through the published port, not this address) succeeds. Every public URL in this dev
# stack is already hardcoded to localhost (see PUBLIC_WEB_URL, VITE_LIVEKIT_URL), so the
# browser and this container are always on the same machine — advertise that directly.
NODE_IP: 127.0.0.1
restart: unless-stopped
# One-shot: creates the Ping project and its two OIDC apps in Musify's Zitadel instance
# (or reuses them if they already exist), then writes AUTH_CLIENT_ID / WEB_CLIENT_ID back
# into the env file mounted at /workspace/.env — so a later `--profile apps up` picks up
# real values instead of empty ones. Both apps are public clients (PKCE, no secret): the
# Scalar docs UI and the React SPA.
zitadel-init:
image: alpine:3.20
container_name: ping-zitadel-init
volumes:
- ${ZITADEL_ADMIN_PAT_DIR}:/zitadel-output:ro
- ./.env:/workspace/.env
environment:
PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL}
PUBLIC_API_URL: ${PUBLIC_API_URL}
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
apk add --no-cache curl jq >/dev/null
ENV_FILE=/workspace/.env
if [ ! -f "$$ENV_FILE" ]; then
echo "no env file mounted at $$ENV_FILE, skipping"
exit 0
fi
env_set() {
if grep -qE "^$$2=" "$$1"; then
awk -v k="$$2" -v v="$$3" '{ sub(/\r$$/, "") } index($$0,k "=")==1{print k "=" v; next} {print}' "$$1" >"$$1.tmp" && cat "$$1.tmp" >"$$1" && rm -f "$$1.tmp"
else
printf '%s=%s\n' "$$2" "$$3" >>"$$1"
fi
}
if [ ! -f /zitadel-output/admin-sa.pat ]; then
echo "admin-sa.pat not found under ZITADEL_ADMIN_PAT_DIR — is Musify's Zitadel running?" >&2
exit 1
fi
PAT=$$(tr -d '\r\n' </zitadel-output/admin-sa.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
deadline=$$(( $$(date +%s) + 60 ))
until curl -fsS "$$PUBLIC_AUTH_URL/.well-known/openid-configuration" >/dev/null 2>&1; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "Zitadel did not respond at $$PUBLIC_AUTH_URL" >&2; exit 1; }
sleep 3
done
api_call() {
if [ $$# -ge 3 ]; then
curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$3"
else
curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT"
fi
}
name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; }
echo "==> project"
PROJECT_NAME=Ping
PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty')
if [ -z "$$PROJECT_ID" ]; then
PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id')
echo " created (id $$PROJECT_ID)"
else
echo " reused (id $$PROJECT_ID)"
fi
find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; }
echo "==> API app (Scalar)"
API_APP_NAME="Ping API (Scalar)"
API_APP_ID=$$(find_app "$$API_APP_NAME")
if [ -n "$$API_APP_ID" ]; then
API_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$API_APP_ID" | jq -r '.app.oidcConfig.clientId')
echo " reused (clientId $$API_CLIENT_ID)"
else
API_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$API_APP_NAME" --arg api "$$PUBLIC_API_URL" '{
name: $$name,
redirectUris: [($$api + "/scalar/"), ($$api + "/scalar/oauth2-redirect.html")],
postLogoutRedirectUris: [($$api + "/scalar/")],
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
appType: "OIDC_APP_TYPE_USER_AGENT",
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE",
version: "OIDC_VERSION_1_0",
devMode: ($$ENV.ZITADEL_DEV_MODE == "true"),
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
accessTokenRoleAssertion: true,
idTokenRoleAssertion: true,
idTokenUserinfoAssertion: true
}')" | jq -r '.clientId')
echo " created (clientId $$API_CLIENT_ID)"
fi
echo "==> web app (React SPA — public client, PKCE)"
WEB_APP_NAME="Ping Web"
WEB_APP_ID=$$(find_app "$$WEB_APP_NAME")
if [ -n "$$WEB_APP_ID" ]; then
WEB_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId')
echo " reused (clientId $$WEB_CLIENT_ID)"
else
WEB_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$WEB_APP_NAME" --arg web "$$PUBLIC_WEB_URL" '{
name: $$name,
redirectUris: [($$web + "/auth/callback")],
postLogoutRedirectUris: [($$web + "/")],
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
appType: "OIDC_APP_TYPE_USER_AGENT",
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE",
version: "OIDC_VERSION_1_0",
devMode: ($$ENV.ZITADEL_DEV_MODE == "true"),
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
accessTokenRoleAssertion: true,
idTokenRoleAssertion: true,
idTokenUserinfoAssertion: true
}')" | jq -r '.clientId')
echo " created (clientId $$WEB_CLIENT_ID)"
fi
env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID"
env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID"
echo "==> wired into $$ENV_FILE"
restart: "no"
# One-shot: SeaweedFS does not auto-create buckets. Reuses Musify's SeaweedFS instance
# (same S3 endpoint/credentials) but creates Ping's own, separate bucket.
seaweedfs-init:
image: amazon/aws-cli:latest
container_name: ping-seaweedfs-init
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
AWS_DEFAULT_REGION: us-east-1
entrypoint: ["/bin/sh", "-c"]
command:
- |
aws --endpoint-url ${S3_PUBLIC_URL} s3 mb s3://${S3_BUCKET} 2>/dev/null || true
aws --endpoint-url ${S3_PUBLIC_URL} s3 ls
restart: "no"
# One-shot: applies the EF Core migrations, then exits.
migrate:
build:
context: ..
dockerfile: Ping.Migrator.Dockerfile
container_name: ping-migrate
depends_on:
postgres:
condition: service_healthy
environment:
Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
restart: "no"
# --- Applications (profile: apps) -----------------------------------------
api:
profiles: [apps]
build:
context: ..
dockerfile: Ping.Api.Dockerfile
container_name: ping-api
depends_on:
<<: *app-depends
migrate:
condition: service_completed_successfully
environment:
<<: *backend-env
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
ASPNETCORE_URLS: http://+:5211
# AUTH_CLIENT_ID is written to the env file by the zitadel-init service.
Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration"
Authentication__IssuerAddress: ${PUBLIC_AUTH_URL}
Authentication__ClientId: ${AUTH_CLIENT_ID}
Authentication__AudienceAddress: ${AUTH_CLIENT_ID}
Authentication__AuthorizationEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/authorize?prompt=login"
Authentication__TokenEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/token"
Authentication__ScalarRedirectUri: "${PUBLIC_API_URL}/scalar/"
Authentication__Scopes__0: openid
Authentication__Scopes__1: profile
Authentication__Scopes__2: email
Authentication__RequireHttpsMetadata: ${AUTH_REQUIRE_HTTPS}
LiveKit__ApiKey: ${LIVEKIT_API_KEY}
LiveKit__ApiSecret: ${LIVEKIT_API_SECRET}
restart: unless-stopped
web:
profiles: [apps]
build:
context: ../../frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: ${PUBLIC_API_URL}
VITE_AUTH_AUTHORITY: ${PUBLIC_AUTH_URL}
VITE_AUTH_CLIENT_ID: ${WEB_CLIENT_ID}
VITE_AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback"
VITE_LIVEKIT_URL: ws://localhost:7880
container_name: ping-web
depends_on:
- api
restart: unless-stopped
volumes:
postgres_data: