diff --git a/.github/workflows/test-nginx.yml b/.github/workflows/test-nginx.yml index c77b0a5..90894a0 100644 --- a/.github/workflows/test-nginx.yml +++ b/.github/workflows/test-nginx.yml @@ -27,6 +27,7 @@ jobs: run: | docker run --rm \ --add-host=web:127.0.0.1 \ + --add-host=powersync:127.0.0.1 \ --volume ${{ github.workspace }}/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro \ nginx:alpine nginx -t @@ -45,7 +46,7 @@ jobs: - name: Start test services run: | cd tests - docker compose -f docker-compose.test.yml up -d web nginx + docker compose -f docker-compose.test.yml up -d web powersync nginx # Wait for services to be healthy echo "Waiting for services to be ready..." diff --git a/config/Caddyfile.example b/config/Caddyfile.example index 68bf06f..d1668b7 100644 --- a/config/Caddyfile.example +++ b/config/Caddyfile.example @@ -36,15 +36,6 @@ localhost { encode - # or "reverse_proxy anubis:3000 {" if you are using Anubis - reverse_proxy web:8000 { - header_up Host {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {http.X-Forwarded-For} {remote_host} - header_up X-Forwarded-Proto {scheme} - header_up X-Http-Version {http.request.proto} - } - handle /static/* { root * /wger @@ -59,6 +50,21 @@ localhost { root * /wger file_server } + + # Reverse proxy for the PowerSync service, used by the mobile app for offline mode. + handle_path /ps/* { + reverse_proxy powersync:8080 + } + + # Catch-all: everything not matched above goes to the wger backend. + # Replace with `reverse_proxy anubis:3000` if you are using Anubis. + reverse_proxy web:8000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {http.X-Forwarded-For} {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Http-Version {http.request.proto} + } } # Refer to the Caddy docs for more information: diff --git a/config/dev.env b/config/dev.env index c90e1b8..573c4dd 100644 --- a/config/dev.env +++ b/config/dev.env @@ -1,6 +1,19 @@ DJANGO_DEBUG=True WGER_USE_GUNICORN=False EXERCISE_CACHE_TTL=30 +DJANGO_PERFORM_MIGRATIONS=True SYNC_EXERCISES_ON_STARTUP=False +DOWNLOAD_EXERCISE_IMAGES_ON_STARTUP=False +DOWNLOAD_EXERCISE_VIDEOS_ON_STARTUP=False +LOAD_ONLINE_FIXTURES_ON_STARTUP=False # a couple of ingredients AXES_ENABLED=False DJANGO_STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage + +# These can be generated with docker compose exec web ./manage.py generate-powersync-keys +#POWERSYNC_JWKS_PUBLIC_KEY=eyJhbGciOiAiUlMyNTYiLCAia3R5IjogIlJTQSIsICJuIjogInFhdVVnb0ZXenRNcjVEYks3bFIxZXUxazJrdllyblJkRGh1NDFyWnFLeWhDWkJya0FTS0d0N25KbVUwVEpKb1d0cFF2eHVvc0ZGeW1BMUhXQnNaY0dtVlcxdlowdDJlazl4THg5bjg2UWRIVWc1MktsRG9ZUzNtRTFaWW5BYzJfRDM3UmxyQkVxRXpuSnBNeDJ3VkpLcVdRZHlWSWh6Q082YzRnOWN3VExGbUhkVXVURXMzdDNBN1MyNENrUkM2TE1KSFFvRTJzay1uWlJyZE9fTHVNNUJJcVp2b1dWUC1Salp4OWk4OGdaaDhvOEcyWW1xZnMwczRzYW1fam85bmFaYlo4aFBFQ0FZdnZUZ29ObzRHMGpXZERZeGdPWHlXTE80bTk1SEdMSFJMZjZ5M29vdkZad2QwN2FFbThEU3dBX3hsY1V4WHNNZ0ZlYVVVZkp2NEV4USIsICJlIjogIkFRQUIiLCAia2lkIjogInBvd2Vyc3luYyJ9 +#POWERSYNC_JWKS_PRIVATE_KEY=eyJhbGciOiAiUlMyNTYiLCAia3R5IjogIlJTQSIsICJuIjogInFhdVVnb0ZXenRNcjVEYks3bFIxZXUxazJrdllyblJkRGh1NDFyWnFLeWhDWkJya0FTS0d0N25KbVUwVEpKb1d0cFF2eHVvc0ZGeW1BMUhXQnNaY0dtVlcxdlowdDJlazl4THg5bjg2UWRIVWc1MktsRG9ZUzNtRTFaWW5BYzJfRDM3UmxyQkVxRXpuSnBNeDJ3VkpLcVdRZHlWSWh6Q082YzRnOWN3VExGbUhkVXVURXMzdDNBN1MyNENrUkM2TE1KSFFvRTJzay1uWlJyZE9fTHVNNUJJcVp2b1dWUC1Salp4OWk4OGdaaDhvOEcyWW1xZnMwczRzYW1fam85bmFaYlo4aFBFQ0FZdnZUZ29ObzRHMGpXZERZeGdPWHlXTE80bTk1SEdMSFJMZjZ5M29vdkZad2QwN2FFbThEU3dBX3hsY1V4WHNNZ0ZlYVVVZkp2NEV4USIsICJlIjogIkFRQUIiLCAiZCI6ICJQZXVwNjhUakZ1RVhaQmFoRWNDT0RWcEUwNndaZkhWb0hvVjhmQk9maEhlUlh6STNJcmprZkhtWHV0UlhsNlNLaElCcFBVbHA0OVo2R2IwTWhIVncySXRDV1hvaFYydkNWdzg1Y2RHMXc1NmQxWml4b2UzZnZ1LXV6RG9icXp0WXJvR0VZTi1jZHVWMS1HeUFwZU4wYzlWdmR5UUtwNWZQbUVGTFl4amlxR3k5UUhyTldpcGJmZXdPUGY0YUl4X05VRnE3R1BsUk1yalA4VEhvSzNPOVNfXzJpR09LRVpINDFUWkpscVBZX0s5dFNkbFNKd1FPWEtwOFc2ZUdGT3l2MElueVhsUXhHb0ZBWVNrUC12WTlWQy1vTUtzdmhocm1GeGM0VlU2OUZ3VWFJYUdaOU9jaXF4M3B0aE9sU1drRjFhbEtxNWFJZ2VHbEUzM2VyNGthSXciLCAicCI6ICI1WDN0QzN4Z0hwbm91U1JwSlg4c0ZWRm5vamhxMWJoWkF3c3VRaXBxWWgtZmJNRGI4a2NTTy1fT3BEMExNekYzcHp0dVNRb0NZOFc0WjI0TEJ6cFRuUlFid0JrYWt3VDMybmZIU1J0d3RnM1ZjWkkxZFNsdHgtclhEcHlBMDNHa1RvLUxEZkp1UzF0a1FYQXB0OTBkcnJHMndjQ25oRXc4bGx2SzR2cWRucHMiLCAicSI6ICJ2VVM2V2QtY2trTUJMVmJvSkVaVnRtMlFLTFE2dV9oZEFrbTFWa3dGajMxZWZWRTlFRWRSa0F0dGVoOWh2ZzBkM2FXVDZ1bFQ4YlpubWo3WkFjNG55aVdwOTlFd0k5U0hFX01UUE11YVZSeUw5SmFIX2R0Uk5nVGE5UV9hZUs2d1pkY3RwLUZRT1lteVlDWmhzRnVOTG45TFJ3UklJOVJ0YlBXYW55X01jQjgiLCAiZHAiOiAiWjFNNkhmakN3aVJqcnJBaEV6dmQyajlMbkxNd0RzZXdjX2xkdTNhamJVaDFuQjU5S09rczRZV0lFVlJXclpieEczOWJtVkVEWUc2T0p5dFpsY2lDQ3ZBWnluVEREVHlvWjFtVWhXcndaVmQzS1dvOTNXRm94eUVKOE04d0JZTmVDZTBCRzZkeVYwVnZyekxUNWEtTmhMRUk2dFZWMXZBSU8xNWF5N1V3c0U4IiwgImRxIjogIktsclpBUWZEZUEtNmtiVGpHa3NMSDFvQmFycDZjbG93SmpUc2ViVmxnU2pqSGxReHdCVFZzZEI4M1Zsc2ZDVmZTNXlrTDJ1cnQybkVZWVl5OWU1MmhReE1yd0tITFYyQUpQeS1qMXBZM1RjWU10SUUtTkE5cWtNSDVOTjVab3hoT1VrZ0ZIT2RpbUxBSWpnMG9FeThtVzB2SVdOWjZYcS1TaVhrUmo5aUZxMCIsICJxaSI6ICJzSV84RTh0MTBsRDY2NTh3UXRpY19BaUUxOVk1Rms0SDJWbnpGclBhVU04aWFNaVc2eUZxMFZuN3RXa2RTWS1STTB1SFMwdmVmSEcyZTBKSWxEanhBUmZWZUcwNTFyVUNRZjBkSnR4U0ZDQUp2eGxMRTZsYjZOQlUwZVIyMld6bjVob1ZZTVpHZnQ5QnA0SlVOOHJkMF9lMm1kSjhxc09wM1NLQ3NTSTByUkkiLCAia2lkIjogInBvd2Vyc3luYyJ9 +POWERSYNC_URL=http://localhost:8080 +#POWERSYNC_URL=http://10.0.2.2:8080 # When developing on the android emulator + +SECRET_KEY='dev1-docker-supersecret-key-1234567890!@#$%^&*(-_)+=' +SIGNING_KEY='dev1-docker-secret-jwtkey-1234567890!@#$%^&*(-_=+)' diff --git a/config/nginx.conf b/config/nginx.conf index 979646b..33cac2e 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -2,6 +2,12 @@ upstream wger { server web:8000; } +# Used by the /ps/ proxy below. The nginx service's `depends_on:` lists +# `powersync` so it is started before nginx tries to resolve this hostname. +upstream powersync { + server powersync:8080; +} + # JSON access log format compatible with the Loki/Alloy parser # (same field names as the Caddy parser, so the same dashboard works). log_format json_access escape=json @@ -55,6 +61,27 @@ server { proxy_send_timeout 86400s; } + # Reverse proxy for the PowerSync service, used by the mobile app for offline mode + location /ps/ { + proxy_pass http://powersync/; + + 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 $final_forwarded_proto; + + # WebSocket upgrade (used by the diagnostics app and the Web SDK) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Sync streams are long-lived (HTTP/2 streaming POST) and must not + # be buffered or cut off by short timeouts. + proxy_buffering off; + proxy_read_timeout 1d; + proxy_send_timeout 1d; + } + location /static/ { alias /wger/static/; diff --git a/config/prod.env b/config/prod.env index 1f1709c..bb4970e 100644 --- a/config/prod.env +++ b/config/prod.env @@ -1,19 +1,41 @@ +# +# Change these settings +# + # Django's secret keys, change to a 50 character random string. Generate e.g. with: # * python -c "import secrets; print(secrets.token_urlsafe(50))" or # * https://djecrety.ir/ -# Note that if you leave these keys unchanged, new ones will be generated on every +# Note that if you leave this key unchanged, a new one will be generated on every # startup, which means that for example all sessions will be invalidated every time # you restart the server. SECRET_KEY=wger-docker-supersecret-key-1234567890!@#$%^&*(-_) -# Signing key used for JWT, use something different than the secret key -SIGNING_KEY=wger-docker-secret-jwtkey-1234567890!@#$%^&*(-_=+) +# The URL where the application will be available. +SITE_URL=http://localhost + +# JWT keys, used by the mobile app. This default NEEDS to be changed. +# Generate fresh keys with: docker compose exec web ./manage.py generate-jwt-keys +JWT_PUBLIC_KEY=eyJhbGciOiAiUlMyNTYiLCAia3R5IjogIlJTQSIsICJuIjogInFhdVVnb0ZXenRNcjVEYks3bFIxZXUxazJrdllyblJkRGh1NDFyWnFLeWhDWkJya0FTS0d0N25KbVUwVEpKb1d0cFF2eHVvc0ZGeW1BMUhXQnNaY0dtVlcxdlowdDJlazl4THg5bjg2UWRIVWc1MktsRG9ZUzNtRTFaWW5BYzJfRDM3UmxyQkVxRXpuSnBNeDJ3VkpLcVdRZHlWSWh6Q082YzRnOWN3VExGbUhkVXVURXMzdDNBN1MyNENrUkM2TE1KSFFvRTJzay1uWlJyZE9fTHVNNUJJcVp2b1dWUC1Salp4OWk4OGdaaDhvOEcyWW1xZnMwczRzYW1fam85bmFaYlo4aFBFQ0FZdnZUZ29ObzRHMGpXZERZeGdPWHlXTE80bTk1SEdMSFJMZjZ5M29vdkZad2QwN2FFbThEU3dBX3hsY1V4WHNNZ0ZlYVVVZkp2NEV4USIsICJlIjogIkFRQUIiLCAia2lkIjogInBvd2Vyc3luYyJ9 +JWT_PRIVATE_KEY=eyJhbGciOiAiUlMyNTYiLCAia3R5IjogIlJTQSIsICJuIjogInFhdVVnb0ZXenRNcjVEYks3bFIxZXUxazJrdllyblJkRGh1NDFyWnFLeWhDWkJya0FTS0d0N25KbVUwVEpKb1d0cFF2eHVvc0ZGeW1BMUhXQnNaY0dtVlcxdlowdDJlazl4THg5bjg2UWRIVWc1MktsRG9ZUzNtRTFaWW5BYzJfRDM3UmxyQkVxRXpuSnBNeDJ3VkpLcVdRZHlWSWh6Q082YzRnOWN3VExGbUhkVXVURXMzdDNBN1MyNENrUkM2TE1KSFFvRTJzay1uWlJyZE9fTHVNNUJJcVp2b1dWUC1Salp4OWk4OGdaaDhvOEcyWW1xZnMwczRzYW1fam85bmFaYlo4aFBFQ0FZdnZUZ29ObzRHMGpXZERZeGdPWHlXTE80bTk1SEdMSFJMZjZ5M29vdkZad2QwN2FFbThEU3dBX3hsY1V4WHNNZ0ZlYVVVZkp2NEV4USIsICJlIjogIkFRQUIiLCAiZCI6ICJQZXVwNjhUakZ1RVhaQmFoRWNDT0RWcEUwNndaZkhWb0hvVjhmQk9maEhlUlh6STNJcmprZkhtWHV0UlhsNlNLaElCcFBVbHA0OVo2R2IwTWhIVncySXRDV1hvaFYydkNWdzg1Y2RHMXc1NmQxWml4b2UzZnZ1LXV6RG9icXp0WXJvR0VZTi1jZHVWMS1HeUFwZU4wYzlWdmR5UUtwNWZQbUVGTFl4amlxR3k5UUhyTldpcGJmZXdPUGY0YUl4X05VRnE3R1BsUk1yalA4VEhvSzNPOVNfXzJpR09LRVpINDFUWkpscVBZX0s5dFNkbFNKd1FPWEtwOFc2ZUdGT3l2MElueVhsUXhHb0ZBWVNrUC12WTlWQy1vTUtzdmhocm1GeGM0VlU2OUZ3VWFJYUdaOU9jaXF4M3B0aE9sU1drRjFhbEtxNWFJZ2VHbEUzM2VyNGthSXciLCAicCI6ICI1WDN0QzN4Z0hwbm91U1JwSlg4c0ZWRm5vamhxMWJoWkF3c3VRaXBxWWgtZmJNRGI4a2NTTy1fT3BEMExNekYzcHp0dVNRb0NZOFc0WjI0TEJ6cFRuUlFid0JrYWt3VDMybmZIU1J0d3RnM1ZjWkkxZFNsdHgtclhEcHlBMDNHa1RvLUxEZkp1UzF0a1FYQXB0OTBkcnJHMndjQ25oRXc4bGx2SzR2cWRucHMiLCAicSI6ICJ2VVM2V2QtY2trTUJMVmJvSkVaVnRtMlFLTFE2dV9oZEFrbTFWa3dGajMxZWZWRTlFRWRSa0F0dGVoOWh2ZzBkM2FXVDZ1bFQ4YlpubWo3WkFjNG55aVdwOTlFd0k5U0hFX01UUE11YVZSeUw5SmFIX2R0Uk5nVGE5UV9hZUs2d1pkY3RwLUZRT1lteVlDWmhzRnVOTG45TFJ3UklJOVJ0YlBXYW55X01jQjgiLCAiZHAiOiAiWjFNNkhmakN3aVJqcnJBaEV6dmQyajlMbkxNd0RzZXdjX2xkdTNhamJVaDFuQjU5S09rczRZV0lFVlJXclpieEczOWJtVkVEWUc2T0p5dFpsY2lDQ3ZBWnluVEREVHlvWjFtVWhXcndaVmQzS1dvOTNXRm94eUVKOE04d0JZTmVDZTBCRzZkeVYwVnZyekxUNWEtTmhMRUk2dFZWMXZBSU8xNWF5N1V3c0U4IiwgImRxIjogIktsclpBUWZEZUEtNmtiVGpHa3NMSDFvQmFycDZjbG93SmpUc2ViVmxnU2pqSGxReHdCVFZzZEI4M1Zsc2ZDVmZTNXlrTDJ1cnQybkVZWVl5OWU1MmhReE1yd0tITFYyQUpQeS1qMXBZM1RjWU10SUUtTkE5cWtNSDVOTjVab3hoT1VrZ0ZIT2RpbUxBSWpnMG9FeThtVzB2SVdOWjZYcS1TaVhrUmo5aUZxMCIsICJxaSI6ICJzSV84RTh0MTBsRDY2NTh3UXRpY19BaUUxOVk1Rms0SDJWbnpGclBhVU04aWFNaVc2eUZxMFZuN3RXa2RTWS1STTB1SFMwdmVmSEcyZTBKSWxEanhBUmZWZUcwNTFyVUNRZjBkSnR4U0ZDQUp2eGxMRTZsYjZOQlUwZVIyMld6bjVob1ZZTVpHZnQ5QnA0SlVOOHJkMF9lMm1kSjhxc09wM1NLQ3NTSTByUkkiLCAia2lkIjogInBvd2Vyc3luYyJ9 # The server's timezone, for a list of possible names: # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones TIME_ZONE=Europe/Berlin TZ=Europe/Berlin +# +# These settings usually don't need changing +# + +# +# Other powersync settings +PS_STORAGE_PG_URI=postgres://powersync_storage:powersync_password@db:5432/wger +PS_PORT=8080 +POWERSYNC_URL_PATH='ps' +# The full url for the powersync service, overrides POWERSYNC_URL_PATH if set. Only +# set if you know what you are doing and have a very different setup +#POWERSYNC_URL=https://ps.my-domain.example.com:1234 + # # If you get CSRF errors set your domain here # Consult the docs for more details: @@ -31,10 +53,6 @@ TZ=Europe/Berlin # MEDIA_URL=https://your-domain.example.com/media/ # STATIC_URL=https://your-domain.example.com/static/ -# -# These settings usually don't need changing -# - # # Application WGER_INSTANCE=https://wger.de # Wger instance from which to sync exercises, images, etc. @@ -99,12 +117,14 @@ CELERY_WORKER_CONCURRENCY=4 # Set to one if using sqlite # # Database -DJANGO_DB_ENGINE=django.db.backends.postgresql -DJANGO_DB_DATABASE=wger -DJANGO_DB_USER=wger -DJANGO_DB_PASSWORD=wger -DJANGO_DB_HOST=db -DJANGO_DB_PORT=5432 +# +# The Postgres container reads POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB +# directly from this file. If you change the password, also update it inside PS_DATABASE_URI. +POSTGRES_USER=wger +POSTGRES_PASSWORD=wger +POSTGRES_DB=wger +PS_DATABASE_URI=postgres://wger:wger@db:5432/wger + DJANGO_PERFORM_MIGRATIONS=True # Perform any new database migrations on startup # @@ -135,7 +155,6 @@ AXES_IPWARE_META_PRECEDENCE_ORDER=HTTP_X_FORWARDED_FOR,REMOTE_ADDR DJANGO_DEBUG=False WGER_USE_GUNICORN=True EXERCISE_CACHE_TTL=86400 # in seconds - 24*60*60, 24 hours -SITE_URL=http://localhost WGER_PORT=8000 # Only change if you have a very different setup and know what you are doing # @@ -197,9 +216,6 @@ FROM_EMAIL='wger Workout Manager ' # Needs a working email configuration # DJANGO_ADMINS=your name,email@example.com -# Whether to compress css and js files into one (of each) -# COMPRESS_ENABLED=True - # # Django Rest Framework # The number of proxies in front of the application. In the default configuration @@ -236,4 +252,4 @@ EXPOSE_PROMETHEUS_METRICS=False #USE_S3_URL_FOR_STATIC=True #S3_MEDIA_FILES_LOCATION=media #S3_STATIC_FILES_LOCATION=static -#AWS_QUERYSTRING_AUTH=False \ No newline at end of file +#AWS_QUERYSTRING_AUTH=False diff --git a/dev-postgres/docker-compose.yml b/dev-postgres/docker-compose.yml index 9f32e66..b4c8d2b 100644 --- a/dev-postgres/docker-compose.yml +++ b/dev-postgres/docker-compose.yml @@ -2,6 +2,7 @@ name: wger-dev-postgres services: web: + profiles: [disabled] build: pull: true context: ${WGER_CODEPATH:?set the absolute path to the wger backend code in the .env file or env variable} @@ -15,45 +16,44 @@ services: path: ${WGER_CODEPATH}/pyproject.toml - action: rebuild path: ${WGER_CODEPATH}/package.json + depends_on: + db: + condition: service_healthy env_file: - ../config/prod.env - ../config/dev.env ports: - "8000:8000" - command: tail -f /dev/null - - cache: - image: redis - expose: - - 6379 - healthcheck: - test: redis-cli ping - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped + # No need to persist the data in dev +# cache: +# volumes: !override +# - ../config/redis.conf:/usr/local/etc/redis/redis.conf db: - image: postgres:15-alpine - environment: - - POSTGRES_USER=wger - - POSTGRES_PASSWORD=wger - - POSTGRES_DB=wger - volumes: - - postgres-data-dev:/var/lib/postgresql/data/ + shm_size: 256mb + volumes: !override + - postgres-dev-data:/var/lib/postgresql/data/ + - ./initdb:/docker-entrypoint-initdb.d:ro ports: - "5432:5432" - expose: - - 5432 - healthcheck: - test: pg_isready -U wger - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped + +# To run the backend "locally" on the host while keeping PowerSync and postgres containerised: +# powersync: +# # Linux needs this, macOS/Windows work out of the box +# extra_hosts: +# - "host.docker.internal:host-gateway" +# environment: +# PS_JWKS_URL: http://host.docker.internal:8000/api/v2/powersync-keys volumes: - postgres-data-dev: + postgres-dev-data: + +networks: + default: + name: wger-dev-network + +include: + - path: ../services/postgres.yaml + - path: ../services/powersync.yaml + - path: ../services/redis.yaml diff --git a/dev-postgres/initdb/01-reset-password.sql b/dev-postgres/initdb/01-reset-password.sql new file mode 100644 index 0000000..d9d6f7b --- /dev/null +++ b/dev-postgres/initdb/01-reset-password.sql @@ -0,0 +1,23 @@ +BEGIN; + + +-- Set password to adminadmin +UPDATE auth_user + SET password = 'pbkdf2_sha256$1000000$Pw11yvSIktZVAx8Lcpaizx$dMqOh/9VoCkfxksY9Cm78p6LXvMrYZBoqP31z7TRCj4=' + WHERE username = 'admin'; + +-- Set tokens to known values +UPDATE authtoken_token + SET key = '73f1ee4fbc4f58bfcd777755fc36c6260823a084' + WHERE user_id = (SELECT id FROM auth_user WHERE username = 'admin'); + +UPDATE authtoken_token + SET key = '31e2ea0322c07b9df583a9b6d1e794f7139e78d4' + WHERE user_id = (SELECT id FROM auth_user WHERE username = 'user'); + +-- UPDATE core_userprofile +-- SET email_verified = true +-- WHERE id IN (SELECT id FROM auth_user WHERE username IN ('admin', 'user')); + + +COMMIT; \ No newline at end of file diff --git a/dev-postgres/initdb/02-cleanup.sql b/dev-postgres/initdb/02-cleanup.sql new file mode 100644 index 0000000..38ff102 --- /dev/null +++ b/dev-postgres/initdb/02-cleanup.sql @@ -0,0 +1,7 @@ +BEGIN; + +TRUNCATE TABLE exercises_exerciseimage; +TRUNCATE TABLE exercises_exercisevideo; +TRUNCATE TABLE nutrition_image; + +COMMIT; \ No newline at end of file diff --git a/dev-postgres/initdb/03-reduce-entries.sql b/dev-postgres/initdb/03-reduce-entries.sql new file mode 100644 index 0000000..97c9c2b --- /dev/null +++ b/dev-postgres/initdb/03-reduce-entries.sql @@ -0,0 +1,110 @@ +-- Reduce dump size for faster local migration / testing runs. +-- +-- 0) Make every FK in the DB ON DELETE CASCADE. Django's on_delete is +-- enforced in Python only — at the Postgres level every FK is NO ACTION, +-- so a raw DELETE on auth_user fails on the first child table. Since +-- auth_user cascades across ~54 tables transitively, we bulk-rewrite +-- all FKs instead of chasing the chain by hand. This is destructive +-- for a production DB (SET_NULL/PROTECT semantics are lost), but for a +-- freshly imported dump that we're about to shrink anyway it's fine. +-- +-- 1) Users: keep 'admin', 'user' and 100 random others. With step 0 in +-- place the CASCADE removes dependent rows (routines, plans, sessions, +-- logs, weight entries, images, trophies, gym contracts, simple_history +-- records, admin log, tokens, …) automatically. +-- +-- 2) Ingredients: keep every ingredient still referenced from MealItem, +-- LogItem or IngredientWeightUnit (these three are the only FKs that +-- point at nutrition_ingredient), plus enough random extras to reach +-- 100 000 rows. If more than 100 000 ingredients are referenced they +-- all stay. + +BEGIN; + +-- --------------------------------------------------------------------------- +-- 0. Rewrite every FK in the public schema to ON DELETE CASCADE +-- --------------------------------------------------------------------------- +DO $$ +DECLARE + r record; + col_list text; + ref_col_list text; +BEGIN + FOR r IN + SELECT c.conrelid::regclass AS tbl, + c.confrelid::regclass AS ref_tbl, + c.conname, + c.conkey, + c.confkey + FROM pg_constraint c + WHERE c.contype = 'f' + AND c.confdeltype <> 'c' + AND c.connamespace = 'public'::regnamespace + LOOP + SELECT string_agg(quote_ident(a.attname), ',' ORDER BY k.ord) + INTO col_list + FROM unnest(r.conkey) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_attribute a ON a.attrelid = r.tbl AND a.attnum = k.attnum; + + SELECT string_agg(quote_ident(a.attname), ',' ORDER BY k.ord) + INTO ref_col_list + FROM unnest(r.confkey) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_attribute a ON a.attrelid = r.ref_tbl AND a.attnum = k.attnum; + + EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', + r.tbl, r.conname); + EXECUTE format( + 'ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (%s) ' + 'REFERENCES %s(%s) ON DELETE CASCADE', + r.tbl, r.conname, col_list, r.ref_tbl, ref_col_list); + END LOOP; +END $$; + +-- --------------------------------------------------------------------------- +-- 1. Users +-- --------------------------------------------------------------------------- +WITH users_to_keep AS ( + SELECT id FROM auth_user WHERE username IN ('admin', 'user') + UNION + (SELECT id + FROM auth_user + WHERE username NOT IN ('admin', 'user') + ORDER BY random() + LIMIT 100) +) +DELETE FROM auth_user +WHERE id NOT IN (SELECT id FROM users_to_keep); + +-- --------------------------------------------------------------------------- +-- 2. Ingredients +-- --------------------------------------------------------------------------- +-- Note: user deletion above already cascaded and removed a lot of MealItem / +-- LogItem rows, so the "referenced" set is evaluated against the *reduced* +-- state — which is what we want. +WITH referenced AS ( + SELECT ingredient_id AS id FROM nutrition_mealitem + UNION + SELECT ingredient_id FROM nutrition_logitem + UNION + SELECT ingredient_id FROM nutrition_ingredientweightunit +), +random_extras AS ( + SELECT id + FROM nutrition_ingredient + WHERE id NOT IN (SELECT id FROM referenced) + ORDER BY random() + LIMIT GREATEST(0, 100000 - (SELECT count(*) FROM referenced)) +), +ingredients_to_keep AS ( + SELECT id FROM referenced + UNION + SELECT id FROM random_extras +) +DELETE FROM nutrition_ingredient +WHERE id NOT IN (SELECT id FROM ingredients_to_keep); + +COMMIT; + +-- Reclaim space and refresh planner stats. Must run outside the transaction. +VACUUM ANALYZE auth_user; +VACUUM ANALYZE nutrition_ingredient; diff --git a/dev-postgres/initdb/04-powersync.sql b/dev-postgres/initdb/04-powersync.sql new file mode 100644 index 0000000..dbb6d06 --- /dev/null +++ b/dev-postgres/initdb/04-powersync.sql @@ -0,0 +1,9 @@ +-- Creates a dedicated user and schema for PowerSync's bucket storage, +-- living inside the wger database (separate schema, no separate DB needed). +-- https://docs.powersync.com/configuration/powersync-service/self-hosted-instances#postgres-storage + +CREATE USER powersync_storage WITH PASSWORD 'powersync_password'; +CREATE SCHEMA IF NOT EXISTS powersync AUTHORIZATION powersync_storage; +GRANT CONNECT ON DATABASE wger TO powersync_storage; +GRANT USAGE ON SCHEMA powersync TO powersync_storage; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA powersync TO powersync_storage; \ No newline at end of file diff --git a/dev-postgres/initdb/README.md b/dev-postgres/initdb/README.md new file mode 100644 index 0000000..425e47e --- /dev/null +++ b/dev-postgres/initdb/README.md @@ -0,0 +1,8 @@ +# Postgres init scripts + +This folder contains some SQL scripts that are applied by postgres **once**, on +the very first start against an empty data volume. + +They run some initialisation tasks, such as trimming the db and resetting passwords. +Note that the files are run by postgres in alphabetical order. If you want to skip +a file, just rename it to have a non-recognised extension (e.g. `.txt`). diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 2ea7041..c1e55eb 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -22,16 +22,8 @@ services: ports: - "8000:8000" + # So this never exists command: tail -f /dev/null - cache: - image: redis - expose: - - 6379 - healthcheck: - test: redis-cli ping - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped +include: + - path: ../services/redis.yaml \ No newline at end of file diff --git a/docker-compose.override.example.yml b/docker-compose.override.example.yml index f88a52d..f8e15b8 100644 --- a/docker-compose.override.example.yml +++ b/docker-compose.override.example.yml @@ -26,6 +26,11 @@ services: # Generate the ED25519 private key with "openssl rand -hex 32" anubis: image: ghcr.io/techarohq/anubis:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 pull_policy: always environment: BIND: ":3000" @@ -55,6 +60,11 @@ services: # caddy: # image: docker.io/caddy:latest +# logging: +# driver: json-file +# options: +# max-size: 10m +# max-file: 3 # depends_on: # - web # ports: @@ -75,6 +85,11 @@ services: celery_flower: image: docker.io/wger/server:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 command: /start-flower env_file: - ./config/prod.env diff --git a/docker-compose.yml b/docker-compose.yml index 6511f07..81c2582 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,11 @@ services: web: image: docker.io/wger/server:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 depends_on: db: condition: service_healthy @@ -20,11 +25,6 @@ services: - media:/home/wger/media expose: - 8000 - logging: - driver: json-file - options: - max-size: 5m - max-file: 5 healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000"] interval: 10s @@ -33,10 +33,23 @@ services: retries: 5 restart: unless-stopped + powersync: + depends_on: + db: + condition: service_healthy + web: + condition: service_healthy + nginx: image: docker.io/nginx:stable + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 depends_on: - web + - powersync volumes: - ./config/nginx.conf:/etc/nginx/conf.d/default.conf - static:/wger/static:ro @@ -52,49 +65,18 @@ services: restart: unless-stopped db: - image: docker.io/postgres:15-alpine + shm_size: 256mb environment: - - POSTGRES_USER=wger - - POSTGRES_PASSWORD=wger - - POSTGRES_DB=wger - TZ=Europe/Berlin - volumes: - - postgres-data:/var/lib/postgresql/data/ - expose: - - 5432 logging: driver: json-file options: max-size: 5m max-file: 5 - healthcheck: - test: ["CMD", "pg_isready", "-U", "wger" ] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped cache: - image: docker.io/redis - expose: - - 6379 - logging: - driver: json-file - options: - max-size: 5m - max-file: 5 volumes: - - ./config/redis.conf:/usr/local/etc/redis/redis.conf - redis-data:/data - command: ["redis-server", "/usr/local/etc/redis/redis.conf"] - healthcheck: - test: redis-cli ping - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped # You probably want to limit the memory usage of the cache, otherwise it might # hog all the available memory. Remove or change according to your needs. @@ -102,16 +84,16 @@ services: celery_worker: image: docker.io/wger/server:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 command: /start-worker env_file: - ./config/prod.env volumes: - media:/home/wger/media - logging: - driver: json-file - options: - max-size: 5m - max-file: 5 depends_on: web: condition: service_healthy @@ -125,12 +107,12 @@ services: celery_beat: image: docker.io/wger/server:latest - command: /start-beat logging: driver: json-file options: - max-size: 5m - max-file: 5 + max-size: 10m + max-file: 3 + command: /start-beat volumes: - celery-beat:/home/wger/beat/ env_file: @@ -156,3 +138,8 @@ volumes: networks: default: name: wger_network + +include: + - path: ./services/postgres.yaml + - path: ./services/powersync.yaml + - path: ./services/redis.yaml \ No newline at end of file diff --git a/grafana/dashboards/powersync.json b/grafana/dashboards/powersync.json new file mode 100644 index 0000000..00186b5 --- /dev/null +++ b/grafana/dashboards/powersync.json @@ -0,0 +1,175 @@ +{ + "annotations": { "list": [] }, + "description": "Core PowerSync service metrics (replication lag, sync throughput, storage). See docs/production/offline.rst for setup. Metric source: telemetry.prometheus_port in PowerSync's powersync.yaml, scraped by prometheus.yml job 'powersync'.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "liveNow": false, + "panels": [ + { + "type": "stat", + "title": "Replication lag", + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "targets": [ + { "expr": "powersync_replication_lag_seconds", "refId": "A", "legendFormat": "lag" } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 60 } + ] + } + } + }, + "options": { "colorMode": "background", "graphMode": "area" } + }, + { + "type": "stat", + "title": "Concurrent client connections", + "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "targets": [ + { "expr": "powersync_concurrent_connections", "refId": "A" } + ], + "fieldConfig": { "defaults": { "unit": "short" } }, + "options": { "colorMode": "value", "graphMode": "area" } + }, + { + "type": "stat", + "title": "Total storage used", + "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "targets": [ + { + "expr": "powersync_replication_storage_size_bytes + powersync_operation_storage_size_bytes + powersync_parameter_storage_size_bytes", + "refId": "A", + "legendFormat": "total" + } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } }, + "options": { "colorMode": "value", "graphMode": "area" } + }, + { + "type": "stat", + "title": "Operations synced (rate, 5m)", + "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "targets": [ + { "expr": "rate(powersync_operations_synced_total[5m])", "refId": "A" } + ], + "fieldConfig": { "defaults": { "unit": "ops" } }, + "options": { "colorMode": "value", "graphMode": "area" } + }, + { + "type": "timeseries", + "title": "Sync to clients — throughput", + "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "targets": [ + { + "expr": "rate(powersync_data_synced_bytes_total[1m])", + "refId": "A", + "legendFormat": "uncompressed bytes/s" + }, + { + "expr": "rate(powersync_data_sent_bytes_total[1m])", + "refId": "B", + "legendFormat": "on-wire bytes/s (compressed)" + } + ], + "fieldConfig": { + "defaults": { "unit": "Bps", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } } + } + }, + { + "type": "timeseries", + "title": "Replication from source — throughput", + "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "targets": [ + { + "expr": "rate(powersync_data_replicated_bytes_total[1m])", + "refId": "A", + "legendFormat": "bytes/s" + }, + { + "expr": "rate(powersync_rows_replicated_total[1m])", + "refId": "B", + "legendFormat": "rows/s" + }, + { + "expr": "rate(powersync_transactions_replicated_total[1m])", + "refId": "C", + "legendFormat": "txs/s" + } + ], + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } } + } + }, + { + "type": "timeseries", + "title": "Storage growth by type", + "gridPos": { "x": 0, "y": 12, "w": 24, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "targets": [ + { + "expr": "powersync_replication_storage_size_bytes", + "refId": "A", + "legendFormat": "replication" + }, + { + "expr": "powersync_operation_storage_size_bytes", + "refId": "B", + "legendFormat": "operations (bucket_data)" + }, + { + "expr": "powersync_parameter_storage_size_bytes", + "refId": "C", + "legendFormat": "parameters" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 30, "stacking": { "mode": "normal" } } + } + } + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["powersync", "wger"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "Prometheus", "value": "Prometheus" }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "PowerSync", + "uid": "powersync", + "version": 1, + "weekStart": "" +} diff --git a/grafana/docker-compose.yml b/grafana/docker-compose.yml index ca44874..48ac179 100644 --- a/grafana/docker-compose.yml +++ b/grafana/docker-compose.yml @@ -1,6 +1,11 @@ services: prometheus: image: prom/prometheus:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 restart: unless-stopped volumes: - prometheus_data:/prometheus @@ -22,6 +27,11 @@ services: grafana: image: grafana/grafana:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 environment: - GF_PROVISIONING_DASHBOARDS_ENABLE=1 - GF_PROVISIONING_DASHBOARDS_PATH=/etc/grafana/provisioning/dashboards @@ -37,6 +47,11 @@ services: loki: image: grafana/loki:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 expose: - 3100 volumes: @@ -47,6 +62,11 @@ services: alloy: image: grafana/alloy:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 volumes: - ./config.alloy:/etc/alloy/config.alloy - /var/run/docker.sock:/var/run/docker.sock:ro @@ -61,6 +81,11 @@ services: node_exporter: image: prom/node-exporter:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 restart: unless-stopped pid: host volumes: diff --git a/grafana/prometheus.yml b/grafana/prometheus.yml index e3a21c0..41e3903 100644 --- a/grafana/prometheus.yml +++ b/grafana/prometheus.yml @@ -19,6 +19,12 @@ scrape_configs: static_configs: - targets: ['web:8000'] + # PowerSync exposes metrics on its own dedicated port (telemetry.prometheus_port + # in config-powersync/powersync.yaml), not on the 8080 sync API port. + - job_name: powersync + static_configs: + - targets: ['powersync:9090'] + - job_name: 'node-exporter' # Override the global default and scrape targets from this job every 5 seconds. diff --git a/services/config-powersync/powersync.yaml b/services/config-powersync/powersync.yaml new file mode 100644 index 0000000..bf504c7 --- /dev/null +++ b/services/config-powersync/powersync.yaml @@ -0,0 +1,71 @@ +# yaml-language-server: $schema=../schema/schema.json + +# Note that this example uses YAML custom tags for environment variable substitution. +# Using `!env [variable name]` will substitute the value of the environment variable named +# [variable name]. +# +# Only environment variables with names starting with `PS_` can be substituted. +# +# e.g. With the environment variable `export PS_STORAGE_MONGO_URI=mongodb://localhost:27017` +# and YAML code: +# uri: !env PS_STORAGE_MONGO_URI +# The YAML will resolve to: +# uri: mongodb://localhost:27017 +# +# If using VS Code see the `.vscode/settings.json` definitions which define custom tags. + +# migrations: +# # Migrations run automatically by default. +# # Setting this to true will skip automatic migrations. +# # Migrations can be triggered externally by altering the container `command`. +# disable_auto_migration: true + +# Settings for telemetry reporting +# See https://docs.powersync.com/self-hosting/telemetry +telemetry: + disable_telemetry_sharing: false + + # Expose prometheus metrics on this port + prometheus_port: 9090 + +# Settings for source database replication +replication: + # Specify database connection details + # Note only 1 connection is currently supported + # Multiple connection support is on the roadmap + connections: + - type: postgresql + uri: !env PS_DATABASE_URI + + # Or use individual params + # hostname: db # From the Docker Compose service name + # port: 5432 + # database: postgres + # username: postgres + # password: mypassword + + # SSL settings + sslmode: disable # 'verify-full' (default) or 'verify-ca' or 'disable' + # 'disable' is OK for local/private networks, not for public networks + + +# Connection settings for sync bucket storage +storage: + type: postgresql + uri: !env PS_STORAGE_PG_URI + sslmode: disable + +# The port which the PowerSync API server will listen on +port: !env PS_PORT + +# Specify sync rules +sync_rules: + path: sync_rules.yaml + +# Client (application end user) authentication settings +client_auth: + allow_local_jwks: true + jwks_uri: !env PS_JWKS_URL + + # JWKS audience + audience: ["powersync-dev", "powersync"] diff --git a/services/config-powersync/sync_rules.yaml b/services/config-powersync/sync_rules.yaml new file mode 100644 index 0000000..d3de3ce --- /dev/null +++ b/services/config-powersync/sync_rules.yaml @@ -0,0 +1,131 @@ +# See Documentation for more information: +# https://docs.powersync.com/usage/sync-rules +# Note that changes to this file are not watched. +# The service needs to be restarted for changes to take effect. +# Warning, parameter queries have a limit of 1000 rows (before filtering)! + +# For details, see the documentation: https://docs.powersync.com/sync/streams/overview +config: + edition: 3 + +streams: + # Core data, common to all users + core: + auto_subscribe: true + queries: + - SELECT * FROM core_language + - SELECT * FROM core_license + - SELECT * FROM core_repetitionunit + - SELECT * FROM core_weightunit + - SELECT * FROM exercises_exercise + - SELECT * FROM exercises_translation + - SELECT * FROM exercises_alias + - SELECT * FROM exercises_exercisecomment + - SELECT * FROM exercises_muscle + - SELECT * FROM exercises_exercise_muscles + - SELECT * FROM exercises_exercise_muscles_secondary + - SELECT * FROM exercises_equipment + - SELECT * FROM exercises_exercise_equipment + - SELECT * FROM exercises_exercisecategory + - SELECT * FROM exercises_exerciseimage + - SELECT * FROM exercises_exercisevideo + + # + # IMPORTANT: + # + # `CAST(user_id AS TEXT)` is required because `auth.user_id()` comes + # from the JWT `sub` claim as a string. Without the cast, PowerSync + # stores the bucket key as integer but announces it to the client as + # string — the client then never finds any ops in the "empty" bucket. + + user_streams: + auto_subscribe: true + with: + user_ingredients: | + SELECT DISTINCT + nutrition_ingredient.id + FROM + nutrition_ingredient + WHERE + -- ingredients directly logged by the user + nutrition_ingredient.id IN ( + SELECT + nutrition_logitem.ingredient_id + FROM + nutrition_logitem + JOIN + nutrition_nutritionplan ON nutrition_logitem.plan_id = nutrition_nutritionplan.id + WHERE + CAST(nutrition_nutritionplan.user_id AS TEXT) = auth.user_id() + ) + OR + -- ingredients added to nutritional plans + nutrition_ingredient.id IN ( + SELECT + nutrition_mealitem.ingredient_id + FROM + nutrition_mealitem + JOIN + nutrition_meal ON nutrition_mealitem.meal_id = nutrition_meal.id + JOIN + nutrition_nutritionplan ON nutrition_meal.plan_id = nutrition_nutritionplan.id + WHERE + CAST(nutrition_nutritionplan.user_id AS TEXT) = auth.user_id() + ) + queries: + # User profile + - SELECT * FROM core_userprofile WHERE CAST(user_id AS TEXT) = auth.user_id() + + # Weight + - SELECT uuid AS id, weight, date, user_id FROM weight_weightentry WHERE CAST(user_id AS TEXT) = auth.user_id() + + # Routines + - SELECT * FROM manager_routine WHERE CAST(user_id AS TEXT) = auth.user_id() AND is_template = FALSE + - SELECT * FROM manager_workoutlog WHERE CAST(user_id AS TEXT) = auth.user_id() + - SELECT * FROM manager_workoutsession WHERE CAST(user_id AS TEXT) = auth.user_id() + + # Measurements + - SELECT * FROM measurements_category WHERE CAST(user_id AS TEXT) = auth.user_id() + - | + SELECT measurements_measurement.* + FROM measurements_measurement + INNER JOIN measurements_category + ON measurements_measurement.category_id = measurements_category.id + WHERE CAST(measurements_category.user_id AS TEXT) = auth.user_id() + + # Nutrition (note: we are restricted by <=1000 distinct ingredient_id values here) + - SELECT * FROM nutrition_nutritionplan WHERE CAST(user_id AS TEXT) = auth.user_id() + - | + SELECT nutrition_meal.* + FROM nutrition_meal + JOIN nutrition_nutritionplan + ON nutrition_meal.plan_id = nutrition_nutritionplan.id + WHERE CAST(nutrition_nutritionplan.user_id AS TEXT) = auth.user_id() + - | + SELECT nutrition_mealitem.* + FROM nutrition_mealitem + JOIN nutrition_meal + ON nutrition_mealitem.meal_id = nutrition_meal.id + JOIN nutrition_nutritionplan + ON nutrition_meal.plan_id = nutrition_nutritionplan.id + WHERE CAST(nutrition_nutritionplan.user_id AS TEXT) = auth.user_id() + - SELECT * FROM nutrition_ingredient WHERE id IN user_ingredients + - | + SELECT nutrition_image.* + FROM + nutrition_image + WHERE nutrition_image.ingredient_id IN user_ingredients + - | + SELECT nutrition_ingredientweightunit.* + FROM + nutrition_ingredientweightunit + WHERE nutrition_ingredientweightunit.ingredient_id IN user_ingredients + - | + SELECT nutrition_logitem.* + FROM nutrition_logitem + JOIN nutrition_nutritionplan + ON nutrition_logitem.plan_id = nutrition_nutritionplan.id + WHERE CAST(nutrition_nutritionplan.user_id AS TEXT) = auth.user_id() + + # Gallery + - SELECT * FROM gallery_image WHERE CAST(user_id AS TEXT) = auth.user_id() diff --git a/services/postgres.yaml b/services/postgres.yaml new file mode 100644 index 0000000..a90cef1 --- /dev/null +++ b/services/postgres.yaml @@ -0,0 +1,32 @@ +services: + db: + image: docker.io/postgres:15-alpine + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 + env_file: + - ../config/prod.env + environment: + - TZ=Europe/Berlin + volumes: + - postgres-data:/var/lib/postgresql/data/ + expose: + - 5432 + command: [ + "postgres", + "-c", "wal_level=logical", + "-c", "shared_buffers=256MB", + "-c", "effective_cache_size=768MB", + "-c", "work_mem=8MB", + "-c", "random_page_cost=1.1", + "-c", "max_connections=30" + ] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\""] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped diff --git a/services/powersync.yaml b/services/powersync.yaml new file mode 100644 index 0000000..066e569 --- /dev/null +++ b/services/powersync.yaml @@ -0,0 +1,69 @@ +services: + powersync: + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + image: docker.io/journeyapps/powersync-service:latest + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 + + # The unified service runs an API server and replication worker in the same container. + # These services can be executed in different containers by using individual entry commands e.g. + # Start only the API server with + # command: ['start', '-r', 'api'] + # Start only the replication worker with + # command: ['start', '-r', 'sync'] + + # Migrations occur automatically by default. Default migrations can be disabled in `powersync.yaml`: + # migrations: + # disable_auto_migration: true + # + # Service migrations can be manually triggered by starting a container with the + # following command: + # command: ['migrate', 'up'] + # Note that this container must finish executing before starting the sync or unified container. + command: ["start", "-r", "unified"] + + volumes: + # Mounts the PowerSync config folder (powersync.yaml + sync_rules.yaml). + # Paths in an `include:`d file are resolved relative to *this* file, + # not to the including docker-compose.yml. Production setups that want + # their own config can override this mount via `volumes: !override` + # in their compose file. + - ./config-powersync:/config:ro + + env_file: + - ../config/prod.env + - ../config/dev.env + + environment: + # This is the path (inside the container) to the YAML config file + # Alternatively the config path can be specified in the command + # e.g: + # command: ['start', '-r', 'unified', '-c', '/config/powersync.yaml'] + # + # The config file can also be specified in Base 64 encoding + # e.g.: Via an environment variable + # POWERSYNC_CONFIG_B64: [base64 encoded content] + # or e.g.: Via a command line parameter + # command: ['start', '-r', 'unified', '-c64', '[base64 encoded content]'] + POWERSYNC_CONFIG_PATH: /config/powersync.yaml + + # Sync rules can be specified as base 64 encoded YAML + # e.g: Via an environment variable + # POWERSYNC_SYNC_RULES_B64: "[base64 encoded sync rules]" + # or e.g.: Via a command line parameter + # command: ['start', '-r', 'unified', '-sync64', '[base64 encoded content]'] + PS_JWKS_URL: http://web:8000/api/v2/powersync-keys + + ports: + - "8080:8080" + + # Expose prometheus metrics + expose: + - 9090 diff --git a/services/redis.yaml b/services/redis.yaml new file mode 100644 index 0000000..fd06f6e --- /dev/null +++ b/services/redis.yaml @@ -0,0 +1,21 @@ +services: + cache: + image: docker.io/redis + logging: + driver: json-file + options: + max-size: 10m + max-file: 3 + expose: + - 6379 + volumes: + - ../config/redis.conf:/usr/local/etc/redis/redis.conf +# - redis-data:/data + command: [ "redis-server", "/usr/local/etc/redis/redis.conf" ] + healthcheck: + test: redis-cli ping + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml index e7417be..6e59d56 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -21,12 +21,26 @@ services: timeout: 3s retries: 3 + # Mock PowerSync service (same echo backend on the port nginx expects) + powersync: + image: python:3.11-slim + container_name: test_powersync + working_dir: /app + volumes: + - ./mock_backend.py:/app/mock_backend.py:ro + command: python3 mock_backend.py 8080 + networks: + - test_network + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080')"] + interval: 5s + timeout: 3s + retries: 3 + # nginx with our configuration nginx: image: nginx:alpine container_name: test_nginx - ports: - - "8080:80" volumes: - ../config/nginx.conf:/etc/nginx/conf.d/default.conf:ro networks: @@ -34,6 +48,8 @@ services: depends_on: web: condition: service_healthy + powersync: + condition: service_healthy healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:80/"] interval: 5s diff --git a/tests/test_nginx_config.py b/tests/test_nginx_config.py index cffc7dc..cd35c34 100644 --- a/tests/test_nginx_config.py +++ b/tests/test_nginx_config.py @@ -132,3 +132,68 @@ def test_host_header_forwarding(self, nginx_url): "Host header not forwarded to backend" assert data['headers']['Host'] == 'example.com', \ f"Expected Host: example.com, got {data['headers']['Host']}" + + +class TestPowerSyncProxy: + """Test the /ps/ reverse proxy to the PowerSync service""" + + def test_strips_ps_prefix_from_path(self, nginx_url): + """Verify /ps/ is forwarded to the upstream as /""" + response = requests.get(f"{nginx_url}/ps/sync/stream") + + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}" + data = response.json() + + assert data['path'] == '/sync/stream', \ + f"Expected upstream path '/sync/stream', got {data['path']}" + + def test_strips_ps_prefix_for_root(self, nginx_url): + """Verify /ps/ (no further path) is forwarded as /""" + response = requests.get(f"{nginx_url}/ps/") + + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}" + data = response.json() + + assert data['path'] == '/', \ + f"Expected upstream path '/', got {data['path']}" + + def test_proxies_websocket_upgrade_headers(self, nginx_url): + """Verify /ps/ proxies WebSocket Upgrade/Connection headers (Web SDK + diagnostics)""" + response = requests.get( + f"{nginx_url}/ps/sync/stream", + headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + }, + ) + + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}" + data = response.json() + + assert data['headers'].get('Upgrade') == 'websocket', \ + f"Expected Upgrade: websocket, got {data['headers'].get('Upgrade')}" + assert data['headers'].get('Connection') == 'upgrade', \ + f"Expected Connection: upgrade, got {data['headers'].get('Connection')}" + + def test_preserves_x_forwarded_proto_from_upstream(self, nginx_url): + """Verify /ps/ respects X-Forwarded-Proto from an upstream reverse proxy""" + response = requests.get( + f"{nginx_url}/ps/sync/stream", + headers={'X-Forwarded-Proto': 'https'}, + ) + + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}" + data = response.json() + + assert data['headers'].get('X-Forwarded-Proto') == 'https', \ + f"Expected X-Forwarded-Proto: https, got {data['headers'].get('X-Forwarded-Proto')}" + + def test_falls_back_to_scheme_without_upstream_proto(self, nginx_url): + """Verify /ps/ falls back to $scheme (http) when no upstream X-Forwarded-Proto is set""" + response = requests.get(f"{nginx_url}/ps/sync/stream") + + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}" + data = response.json() + + assert data['headers'].get('X-Forwarded-Proto') == 'http', \ + f"Expected fallback to $scheme (http), got {data['headers'].get('X-Forwarded-Proto')}"