From 9b65b82ccba58af4691f58ee095298007854bf96 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Sat, 31 Jan 2026 01:56:17 +1100 Subject: [PATCH 001/122] feat(template): cloudflare-ddns --- public/svgs/cloudflare-ddns.svg | 8 ++++++++ templates/compose/cloudflare-ddns.yaml | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 public/svgs/cloudflare-ddns.svg create mode 100644 templates/compose/cloudflare-ddns.yaml diff --git a/public/svgs/cloudflare-ddns.svg b/public/svgs/cloudflare-ddns.svg new file mode 100644 index 000000000..efe800bcc --- /dev/null +++ b/public/svgs/cloudflare-ddns.svg @@ -0,0 +1,8 @@ + + + + + + DDNS + + diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml new file mode 100644 index 000000000..874f2cffb --- /dev/null +++ b/templates/compose/cloudflare-ddns.yaml @@ -0,0 +1,20 @@ +# documentation: https://github.com/favonia/cloudflare-ddns +# slogan: A small, feature-rich, and robust Cloudflare DDNS updater. +# category: automation +# tags: cloud, ddns +# logo: svgs/cloudflare-ddns.svg + +services: + cloudflare-ddns: + image: favonia/cloudflare-ddns:1 + network_mode: host + restart: unless-stopped + user: "1000:1000" + read_only: true + cap_drop: [all] + security_opt: [no-new-privileges:true] + environment: + - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN} + - DOMAINS=${DOMAINS} + - PROXIED=false + - IP6_PROVIDER=none From 90449d2bb5e7b8370dadb0899f511bb9c5b6c2cc Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:55:44 +1100 Subject: [PATCH 002/122] fix: remove restart: unless-stopped Update templates/compose/cloudflare-ddns.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/cloudflare-ddns.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 874f2cffb..b44828a70 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -8,7 +8,6 @@ services: cloudflare-ddns: image: favonia/cloudflare-ddns:1 network_mode: host - restart: unless-stopped user: "1000:1000" read_only: true cap_drop: [all] From 96b9cd3fa543c4e78554af27607ab231d7122e60 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:56:09 +1100 Subject: [PATCH 003/122] fix: mark the API token env as required, and other env as configurable from the UI Update templates/compose/cloudflare-ddns.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/cloudflare-ddns.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index b44828a70..92f857c41 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -13,7 +13,7 @@ services: cap_drop: [all] security_opt: [no-new-privileges:true] environment: - - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN} + - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?} - DOMAINS=${DOMAINS} - - PROXIED=false - - IP6_PROVIDER=none + - PROXIED=${PROXIED:-false} + - IP6_PROVIDER=${IP6_PROVIDER:-none} From b65f6399df736777a48498aca17c43f9609a5a1f Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:52:14 +1100 Subject: [PATCH 004/122] fix: make domains env compulsory Update templates/compose/cloudflare-ddns.yaml --- templates/compose/cloudflare-ddns.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 92f857c41..4f29a98d4 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -14,6 +14,6 @@ services: security_opt: [no-new-privileges:true] environment: - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?} - - DOMAINS=${DOMAINS} + - DOMAINS=${DOMAINS:?} - PROXIED=${PROXIED:-false} - IP6_PROVIDER=${IP6_PROVIDER:-none} From d4e4e446b02c2a0f09bc838e7869ea16138aeffc Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi Date: Wed, 15 Apr 2026 11:30:43 +0300 Subject: [PATCH 005/122] feat: add emqx service template --- public/svgs/emqx-enterprise.svg | 7 +++++++ templates/compose/emqx-enterprise.yaml | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 public/svgs/emqx-enterprise.svg create mode 100644 templates/compose/emqx-enterprise.yaml diff --git a/public/svgs/emqx-enterprise.svg b/public/svgs/emqx-enterprise.svg new file mode 100644 index 000000000..e67e1bffe --- /dev/null +++ b/public/svgs/emqx-enterprise.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml new file mode 100644 index 000000000..1d62ea6f9 --- /dev/null +++ b/templates/compose/emqx-enterprise.yaml @@ -0,0 +1,22 @@ +# documentation: https://www.emqx.io/docs/en/latest/deploy/install-docker.html +# slogan: Open-source MQTT broker for IoT, IIoT, and connected vehicles. +# category: iot +# tags: mqtt,broker,iot,messaging,emqx,iiot +# logo: svgs/emqx-enterprise.svg +# port: 18083 + +services: + emqx: + image: emqx/emqx-enterprise:6.2.0 + environment: + - SERVICE_URL_EMQX_18083 + - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} + volumes: + - emqx_data:/opt/emqx/data + - emqx_log:/opt/emqx/log + healthcheck: + test: ["CMD", "/opt/emqx/bin/emqx", "ctl", "status"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 30s From be6acd6f24983d47d3da0d7e0f520a98320e2da7 Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:11:03 +0300 Subject: [PATCH 006/122] Changed the category of emqx to Networking --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 1d62ea6f9..0f62c1983 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -1,6 +1,6 @@ # documentation: https://www.emqx.io/docs/en/latest/deploy/install-docker.html # slogan: Open-source MQTT broker for IoT, IIoT, and connected vehicles. -# category: iot +# category: Networking # tags: mqtt,broker,iot,messaging,emqx,iiot # logo: svgs/emqx-enterprise.svg # port: 18083 From 950c4e5936c9e5cd179bc913047940bf0825ceaa Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:17:08 +0300 Subject: [PATCH 007/122] Update EMQX image to use 'latest' tag --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 0f62c1983..e68c894bf 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -7,7 +7,7 @@ services: emqx: - image: emqx/emqx-enterprise:6.2.0 + image: emqx/emqx-enterprise:latest environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} From 9208ed102206a67b06ea3dfa978b996106c6500d Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:25:16 +0300 Subject: [PATCH 008/122] Update EMQX image version to 6.2.0 --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index e68c894bf..0f62c1983 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -7,7 +7,7 @@ services: emqx: - image: emqx/emqx-enterprise:latest + image: emqx/emqx-enterprise:6.2.0 environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} From 71771c7d3a2265254e92568955596d0c59016e0d Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:25:48 +0300 Subject: [PATCH 009/122] Add ports configuration for EMQX Enterprise --- templates/compose/emqx-enterprise.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 0f62c1983..01d063b6f 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -11,6 +11,11 @@ services: environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} + ports: + - "1883:1883" + - "8083:8083" + - "8084:8084" + - "8883:8883" volumes: - emqx_data:/opt/emqx/data - emqx_log:/opt/emqx/log From 655e9f4685a4cf487a0212b26d63f46ccff0737f Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 08:34:57 +0200 Subject: [PATCH 010/122] Update ryot.yaml --- templates/compose/ryot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/ryot.yaml b/templates/compose/ryot.yaml index 56190cd5e..41a9ccf0a 100644 --- a/templates/compose/ryot.yaml +++ b/templates/compose/ryot.yaml @@ -7,7 +7,7 @@ services: ryot: - image: ignisda/ryot:v8 + image: ignisda/ryot:v10.3.0 environment: - SERVICE_URL_RYOT_8000 - DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgresql:5432/${POSTGRES_DB} From ea6c63edcf2594181f217216435561ee4c53b8f3 Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 08:58:05 +0200 Subject: [PATCH 011/122] Update jellyfin.yaml --- templates/compose/jellyfin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/jellyfin.yaml b/templates/compose/jellyfin.yaml index d03b66571..59e36821d 100644 --- a/templates/compose/jellyfin.yaml +++ b/templates/compose/jellyfin.yaml @@ -7,7 +7,7 @@ services: jellyfin: - image: lscr.io/linuxserver/jellyfin:latest + image: lscr.io/linuxserver/jellyfin:10.11.8 environment: - SERVICE_URL_JELLYFIN_8096 - PUID=1000 From b678b5852473d2b55a7c7e5882f32ce696d4c907 Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 09:09:36 +0200 Subject: [PATCH 012/122] Update audiobookshelf.yaml --- templates/compose/audiobookshelf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/audiobookshelf.yaml b/templates/compose/audiobookshelf.yaml index d958f56ff..4bb54710d 100644 --- a/templates/compose/audiobookshelf.yaml +++ b/templates/compose/audiobookshelf.yaml @@ -7,7 +7,7 @@ services: audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf:latest + image: ghcr.io/advplyr/audiobookshelf:2.34.0 environment: - SERVICE_URL_AUDIOBOOKSHELF_80 - TZ=${TIMEZONE:-America/Toronto} From 5267b0ad82152746d62be0a25734065a9e196fef Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 09:10:44 +0200 Subject: [PATCH 013/122] Update grocy.yaml --- templates/compose/grocy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/grocy.yaml b/templates/compose/grocy.yaml index 8a014ce70..a78373fdf 100644 --- a/templates/compose/grocy.yaml +++ b/templates/compose/grocy.yaml @@ -6,7 +6,7 @@ services: grocy: - image: lscr.io/linuxserver/grocy:latest + image: lscr.io/linuxserver/grocy:4.6.0 environment: - SERVICE_URL_GROCY - PUID=1000 From dd19d81e491b60b7e8e51d40a82c6b4ebcd1588f Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 09:22:15 +0200 Subject: [PATCH 014/122] Update mealie.yaml --- templates/compose/mealie.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/mealie.yaml b/templates/compose/mealie.yaml index 7f1121613..a2034bce5 100644 --- a/templates/compose/mealie.yaml +++ b/templates/compose/mealie.yaml @@ -7,7 +7,7 @@ services: mealie: - image: 'ghcr.io/mealie-recipes/mealie:latest' + image: 'ghcr.io/mealie-recipes/mealie:3.17.0' environment: - SERVICE_URL_MEALIE_9000 - ALLOW_SIGNUP=${ALLOW_SIGNUP:-true} From fde500a3475cc90e0cb85f17215b4bf2f598fcdf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 15 May 2026 13:36:02 +0200 Subject: [PATCH 015/122] fix(templates): require Docmost mail driver Require MAIL_DRIVER to be set before Docmost starts and add a unit test to keep the compose template and generated service templates in sync. --- templates/compose/docmost.yaml | 2 +- templates/service-templates-latest.json | 2 +- templates/service-templates.json | 2 +- tests/Unit/DocmostMailDriverTemplateTest.php | 23 ++++++++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/DocmostMailDriverTemplateTest.php diff --git a/templates/compose/docmost.yaml b/templates/compose/docmost.yaml index 4a996973e..ce643f629 100644 --- a/templates/compose/docmost.yaml +++ b/templates/compose/docmost.yaml @@ -19,7 +19,7 @@ services: - APP_URL=$SERVICE_URL_DOCMOST_3000 - DATABASE_URL=postgresql://$SERVICE_USER_POSTGRES:$SERVICE_PASSWORD_POSTGRES@postgresql/docmost?schema=public - REDIS_URL=redis://redis:6379 - - MAIL_DRIVER=${MAIL_DRIVER} + - MAIL_DRIVER=${MAIL_DRIVER:?} - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT} - SMTP_USERNAME=${SMTP_USERNAME} diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 4b21a8798..d1cebb2ca 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -887,7 +887,7 @@ "docmost": { "documentation": "https://docmost.com/docs/?utm_source=coolify.io", "slogan": "Open-source collaborative wiki and documentation software", - "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtIEFQUF9TRUNSRVQ9JFNFUlZJQ0VfQkFTRTY0X0FQUEtFWQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcG9zdGdyZXNxbC9kb2Ntb3N0P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3JlZGlzOjYzNzknCiAgICAgIC0gJ01BSUxfRFJJVkVSPSR7TUFJTF9EUklWRVJ9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7U01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ01BSUxfRlJPTV9BRERSRVNTPSR7TUFJTF9GUk9NX0FERFJFU1N9JwogICAgICAtICdNQUlMX0ZST01fTkFNRT0ke01BSUxfRlJPTV9OQU1FfScKICAgICAgLSAnUE9TVE1BUktfVE9LRU49JHtQT1NUTUFSS19UT0tFTn0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2Ntb3N0Oi9hcHAvZGF0YS9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX0RCPWRvY21vc3QKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", + "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtIEFQUF9TRUNSRVQ9JFNFUlZJQ0VfQkFTRTY0X0FQUEtFWQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcG9zdGdyZXNxbC9kb2Ntb3N0P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3JlZGlzOjYzNzknCiAgICAgIC0gJ01BSUxfRFJJVkVSPSR7TUFJTF9EUklWRVI6P30nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnTUFJTF9GUk9NX0FERFJFU1M9JHtNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ01BSUxfRlJPTV9OQU1FPSR7TUFJTF9GUk9NX05BTUV9JwogICAgICAtICdQT1NUTUFSS19UT0tFTj0ke1BPU1RNQVJLX1RPS0VOfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvY21vc3Q6L2FwcC9kYXRhL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfREI9ZG9jbW9zdAogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny4yLWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", "tags": [ "documentation", "opensource", diff --git a/templates/service-templates.json b/templates/service-templates.json index 8dd787f2e..206a8cd6e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -887,7 +887,7 @@ "docmost": { "documentation": "https://docmost.com/docs/?utm_source=coolify.io", "slogan": "Open-source collaborative wiki and documentation software", - "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0NNT1NUXzMwMDAKICAgICAgLSBBUFBfU0VDUkVUPSRTRVJWSUNFX0JBU0U2NF9BUFBLRVkKICAgICAgLSBBUFBfVVJMPSRTRVJWSUNFX0ZRRE5fRE9DTU9TVF8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0Bwb3N0Z3Jlc3FsL2RvY21vc3Q/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnTUFJTF9EUklWRVI9JHtNQUlMX0RSSVZFUn0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnTUFJTF9GUk9NX0FERFJFU1M9JHtNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ01BSUxfRlJPTV9OQU1FPSR7TUFJTF9GUk9NX05BTUV9JwogICAgICAtICdQT1NUTUFSS19UT0tFTj0ke1BPU1RNQVJLX1RPS0VOfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvY21vc3Q6L2FwcC9kYXRhL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfREI9ZG9jbW9zdAogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny4yLWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", + "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0NNT1NUXzMwMDAKICAgICAgLSBBUFBfU0VDUkVUPSRTRVJWSUNFX0JBU0U2NF9BUFBLRVkKICAgICAgLSBBUFBfVVJMPSRTRVJWSUNFX0ZRRE5fRE9DTU9TVF8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0Bwb3N0Z3Jlc3FsL2RvY21vc3Q/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnTUFJTF9EUklWRVI9JHtNQUlMX0RSSVZFUjo/fScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdNQUlMX0ZST01fQUREUkVTUz0ke01BSUxfRlJPTV9BRERSRVNTfScKICAgICAgLSAnTUFJTF9GUk9NX05BTUU9JHtNQUlMX0ZST01fTkFNRX0nCiAgICAgIC0gJ1BPU1RNQVJLX1RPS0VOPSR7UE9TVE1BUktfVE9LRU59JwogICAgdm9sdW1lczoKICAgICAgLSAnZG9jbW9zdDovYXBwL2RhdGEvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19EQj1kb2Ntb3N0CiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=", "tags": [ "documentation", "opensource", diff --git a/tests/Unit/DocmostMailDriverTemplateTest.php b/tests/Unit/DocmostMailDriverTemplateTest.php new file mode 100644 index 000000000..3512017ef --- /dev/null +++ b/tests/Unit/DocmostMailDriverTemplateTest.php @@ -0,0 +1,23 @@ +toContain('MAIL_DRIVER=${MAIL_DRIVER:?}') + ->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}'); + + foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) { + $templates = json_decode( + file_get_contents(__DIR__."/../../templates/{$templateFile}"), + associative: true, + flags: JSON_THROW_ON_ERROR, + ); + + $generatedCompose = base64_decode($templates['docmost']['compose'], strict: true); + + expect($generatedCompose) + ->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}') + ->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}'); + } +}); From bba0cd76d261f5f40755ad6ad7773c3bb50929e4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 15 May 2026 13:41:54 +0200 Subject: [PATCH 016/122] docs(readme): remove CubePath sponsor entry --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 28fc33d6f..e8a7e0c62 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,6 @@ ### Big Sponsors * [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers -* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy * [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design * [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform From 8c0ecedda426616681112686006a339212d598fe Mon Sep 17 00:00:00 2001 From: toanalien Date: Sat, 16 May 2026 08:40:10 +0200 Subject: [PATCH 017/122] feat(templates): add Hermes Agent + WebUI one-click service Two-container template: hermes-agent gateway plus the hermes-webui chat UI. The WebUI is public-facing (gets the generated FQDN and password via Coolify magic vars); the agent stays internal, sharing named volumes. Hermes uses embedded SQLite, so no external database is needed. --- templates/compose/hermes-agent.yaml | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 templates/compose/hermes-agent.yaml diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent.yaml new file mode 100644 index 000000000..d0a7ec6d3 --- /dev/null +++ b/templates/compose/hermes-agent.yaml @@ -0,0 +1,56 @@ +# documentation: https://github.com/nesquena/hermes-webui +# slogan: Hermes Agent — autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI. +# category: ai +# tags: ai, agent, llm, chatbot, hermes, openrouter, anthropic, openai +# logo: svgs/default.webp +# port: 8787 + +services: + hermes-agent: + image: nousresearch/hermes-agent:latest + command: gateway run + environment: + - HERMES_HOME=/home/hermes/.hermes + - HERMES_UID=1000 + - HERMES_GID=1000 + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - GOOGLE_API_KEY=${GOOGLE_API_KEY} + volumes: + - hermes-home:/home/hermes/.hermes + - hermes-agent-src:/opt/hermes + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "test -d /home/hermes/.hermes || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + hermes-webui: + image: ghcr.io/nesquena/hermes-webui:latest + depends_on: + - hermes-agent + environment: + - SERVICE_FQDN_HERMESWEBUI_8787 + - HERMES_WEBUI_HOST=0.0.0.0 + - HERMES_WEBUI_PORT=8787 + - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui + - WANTED_UID=1000 + - WANTED_GID=1000 + - HERMES_WEBUI_PASSWORD=${SERVICE_PASSWORD_HERMESWEBUI} + volumes: + - hermes-home:/home/hermeswebui/.hermes + - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent + - hermes-workspace:/workspace + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8787/health"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + hermes-home: + hermes-agent-src: + hermes-workspace: From 0917bb7b8efcff0a4bd4ebf1906dd8cd15d2a2c9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 16 May 2026 19:06:39 +0200 Subject: [PATCH 018/122] fix(docker): install patched nginx from official repository Pin nginx to the official nginx.org Alpine mainline package in development and production images so patched releases can be installed consistently. --- docker/development/Dockerfile | 15 ++++++++++++++- docker/production/Dockerfile | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index dc9a06c1e..114a25828 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 +# https://nginx.org/en/linux_packages.html +ARG NGINX_VERSION=1.31.0-r1 # ================================================================= # Get MinIO client @@ -24,11 +26,23 @@ ARG GROUP_ID ARG TARGETPLATFORM ARG POSTGRES_VERSION ARG CLOUDFLARED_VERSION +ARG NGINX_VERSION WORKDIR /var/www/html USER root +# Install patched Nginx from the official nginx.org Alpine repository +RUN set -eux; \ + apk add --no-cache ca-certificates curl; \ + NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \ + NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \ + sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \ + grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ + curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ + apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + nginx -v + RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \ docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx @@ -38,7 +52,6 @@ RUN apk upgrade --no-cache && \ mkdir -p /usr/share/keyrings && \ curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg -RUN sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories # Install system dependencies RUN apk add --no-cache \ diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index a01dd595c..56a51373b 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 +# https://nginx.org/en/linux_packages.html +ARG NGINX_VERSION=1.31.0-r1 # Add user/group ARG USER_ID=9999 @@ -20,6 +22,18 @@ FROM serversideup/php:${SERVERSIDEUP_PHP_VERSION} AS base USER root +# Install patched Nginx from the official nginx.org Alpine repository +ARG NGINX_VERSION +RUN set -eux; \ + apk add --no-cache ca-certificates curl; \ + NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \ + NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \ + sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \ + grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ + curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ + apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + nginx -v + ARG USER_ID ARG GROUP_ID @@ -60,12 +74,24 @@ ARG GROUP_ID ARG TARGETPLATFORM ARG POSTGRES_VERSION ARG CLOUDFLARED_VERSION +ARG NGINX_VERSION ARG CI=true WORKDIR /var/www/html USER root +# Install patched Nginx from the official nginx.org Alpine repository +RUN set -eux; \ + apk add --no-cache ca-certificates curl; \ + NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \ + NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \ + sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \ + grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ + curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ + apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + nginx -v + RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \ docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx From 6ceb444cf43ee999df5fe8d062a5fa5835878f6c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 16 May 2026 20:09:25 +0200 Subject: [PATCH 019/122] fix(docker): remove default nginx configs Delete the packaged nginx config files after installing nginx so the image uses the application-provided configuration. --- docker/development/Dockerfile | 1 + docker/production/Dockerfile | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 114a25828..8fc46e32d 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -41,6 +41,7 @@ RUN set -eux; \ grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \ nginx -v RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \ diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 56a51373b..0f849785e 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -32,6 +32,7 @@ RUN set -eux; \ grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \ nginx -v ARG USER_ID @@ -90,6 +91,7 @@ RUN set -eux; \ grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \ nginx -v RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \ From 7dd6d2b13cfa39ffd21d87c635cb0c45cb3daaff Mon Sep 17 00:00:00 2001 From: Tam Nguyen Date: Mon, 18 May 2026 14:52:15 +1000 Subject: [PATCH 020/122] deps: bump cloudflare-ddns to v2.1.2 --- templates/compose/cloudflare-ddns.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 4f29a98d4..e66c33a93 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -6,7 +6,7 @@ services: cloudflare-ddns: - image: favonia/cloudflare-ddns:1 + image: favonia/cloudflare-ddns:2.1.2 network_mode: host user: "1000:1000" read_only: true From bce0c51d3709449404c1c61b341a582209f0449c Mon Sep 17 00:00:00 2001 From: Tam Nguyen Date: Mon, 18 May 2026 15:42:31 +1000 Subject: [PATCH 021/122] fix: cloudflare-ddns 1.16.2 --- templates/compose/cloudflare-ddns.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index e66c33a93..d7f15effb 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -6,7 +6,7 @@ services: cloudflare-ddns: - image: favonia/cloudflare-ddns:2.1.2 + image: favonia/cloudflare-ddns:1.16.2 network_mode: host user: "1000:1000" read_only: true From 270e34fa71ec77cbf6a5a4739ad6a052d972e6fb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 18 May 2026 08:44:50 +0200 Subject: [PATCH 022/122] chore(versions): bump helper and realtime images --- docker-compose.prod.yml | 2 +- docker-compose.windows.yml | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 56c5b416b..3a9bfd501 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index e1c09c64c..cc72d487b 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 368e1e379..b40eafe2c 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -7,7 +7,7 @@ "version": "4.0.0" }, "helper": { - "version": "1.0.13" + "version": "1.0.14" }, "realtime": { "version": "1.0.15" diff --git a/versions.json b/versions.json index 368e1e379..b40eafe2c 100644 --- a/versions.json +++ b/versions.json @@ -7,7 +7,7 @@ "version": "4.0.0" }, "helper": { - "version": "1.0.13" + "version": "1.0.14" }, "realtime": { "version": "1.0.15" From a67cc1d3a9d24b1a8c15e9d7752a3641aace2dd1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 18 May 2026 10:17:33 +0200 Subject: [PATCH 023/122] docs(readme): fix PrivateAlps sponsor wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e8a7e0c62..43ca5c4c3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API * [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs -* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control +* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control ### Big Sponsors From 978d46739d1138833de7071b3882269a4541694b Mon Sep 17 00:00:00 2001 From: Alexandru Furculita Date: Tue, 19 May 2026 10:15:33 +0300 Subject: [PATCH 024/122] feat(service): add openobserve template Adds OpenObserve as a one-click service template. OpenObserve is a cloud-native observability platform for logs, metrics, traces, RUM and session replays, positioned as a self-hosted alternative to Elasticsearch, Splunk and Datadog. - Uses the official open-source image (public.ecr.aws/zinclabs/openobserve) - Wires admin password through Coolify's SERVICE_PASSWORD_* magic env - Persists /data via a named volume - Exposes port 5080 via SERVICE_URL_OPENOBSERVE_5080 - Opts out of telemetry by default (overridable via ZO_TELEMETRY) - Adds /healthz healthcheck and the OpenObserve logo Supersedes #6328, addressing the prior review feedback (drop the deprecated version key, drop hardcoded container_name and restart policy, switch to the magic password env, and use a named volume). --- public/svgs/openobserve.svg | 39 ++++++++++++++++++++++++++++++ templates/compose/openobserve.yaml | 25 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 public/svgs/openobserve.svg create mode 100644 templates/compose/openobserve.yaml diff --git a/public/svgs/openobserve.svg b/public/svgs/openobserve.svg new file mode 100644 index 000000000..c687d948b --- /dev/null +++ b/public/svgs/openobserve.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/compose/openobserve.yaml b/templates/compose/openobserve.yaml new file mode 100644 index 000000000..93239aa19 --- /dev/null +++ b/templates/compose/openobserve.yaml @@ -0,0 +1,25 @@ +# documentation: https://openobserve.ai/docs/ +# slogan: Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays — a 140x cheaper alternative to Elasticsearch, Splunk and Datadog. +# category: monitoring +# tags: logs, metrics, traces, observability, monitoring, opentelemetry, otel, elasticsearch, splunk, datadog +# logo: svgs/openobserve.svg +# port: 5080 + +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + environment: + - SERVICE_URL_OPENOBSERVE_5080 + - ZO_DATA_DIR=/data + - ZO_ROOT_USER_EMAIL=${ZO_ROOT_USER_EMAIL:-root@example.com} + - ZO_ROOT_USER_PASSWORD=${SERVICE_PASSWORD_OPENOBSERVE} + - ZO_TELEMETRY=${ZO_TELEMETRY:-false} + - ZO_COOKIE_SECURE_ONLY=${ZO_COOKIE_SECURE_ONLY:-true} + volumes: + - openobserve-data:/data + healthcheck: + test: ["CMD", "/openobserve", "node", "status"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s From 65c0c92c0258b5cde127915037808b49b6bc4384 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 19 May 2026 12:50:08 +0200 Subject: [PATCH 025/122] fix(destinations): handle empty and server-scoped destinations Build the global destinations list from actual destination records so empty servers do not render duplicate empty states. Allow creating Docker destinations for a selected team server outside the global usable list, authorize swarm creation correctly, and store discovered swarm network names from the selected network. Add feature coverage for empty states, selected-server mounting, and swarm destination creation. --- app/Livewire/Destination/Index.php | 9 +- app/Livewire/Destination/New/Docker.php | 31 ++--- app/Livewire/Server/Destinations.php | 2 +- .../livewire/destination/index.blade.php | 48 ++++---- .../livewire/server/destinations.blade.php | 3 + tests/Feature/ServerDestinationsPageTest.php | 107 ++++++++++++++++++ 6 files changed, 159 insertions(+), 41 deletions(-) create mode 100644 tests/Feature/ServerDestinationsPageTest.php diff --git a/app/Livewire/Destination/Index.php b/app/Livewire/Destination/Index.php index a3df3fd56..7a4b89fab 100644 --- a/app/Livewire/Destination/Index.php +++ b/app/Livewire/Destination/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Destination; use App\Models\Server; +use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -11,9 +12,15 @@ class Index extends Component #[Locked] public $servers; - public function mount() + #[Locked] + public Collection $destinations; + + public function mount(): void { $this->servers = Server::isUsable()->get(); + $this->destinations = $this->servers + ->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers)) + ->values(); } public function render() diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 6f9b6f995..254823163 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -33,44 +33,49 @@ class Docker extends Component #[Validate(['required', 'boolean'])] public bool $isSwarm = false; - public function mount(?string $server_id = null) + public function mount(?string $server_id = null): void { - $this->network = new Cuid2; + $this->network = (string) new Cuid2; $this->servers = Server::isUsable()->get(); - if ($server_id) { - $foundServer = $this->servers->find($server_id) ?: $this->servers->first(); - if (! $foundServer) { - throw new \Exception('Server not found.'); + + if (filled($server_id)) { + $this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail(); + + if (! $this->servers->contains('id', $this->selectedServer->id)) { + $this->servers->push($this->selectedServer); } - $this->selectedServer = $foundServer; - $this->serverId = $this->selectedServer->id; + + $this->serverId = (string) $this->selectedServer->id; } else { $foundServer = $this->servers->first(); if (! $foundServer) { throw new \Exception('Server not found.'); } $this->selectedServer = $foundServer; - $this->serverId = $this->selectedServer->id; + $this->serverId = (string) $this->selectedServer->id; } $this->generateName(); } - public function updatedServerId() + public function updatedServerId(): void { $this->selectedServer = $this->servers->find($this->serverId); + if (! $this->selectedServer) { + throw new \Exception('Server not found.'); + } $this->generateName(); } - public function generateName() + public function generateName(): void { $name = data_get($this->selectedServer, 'name', new Cuid2); $this->name = str("{$name}-{$this->network}")->kebab(); } - public function submit() + public function submit(): mixed { try { - $this->authorize('create', StandaloneDocker::class); + $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class); $this->validate(); if ($this->isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index 117b43ad6..f3f142646 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -45,7 +45,7 @@ public function add($name) } else { SwarmDocker::create([ 'name' => $this->server->name.'-'.$name, - 'network' => $this->name, + 'network' => $name, 'server_id' => $this->server->id, ]); } diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php index aecd58d7a..d5026a651 100644 --- a/resources/views/livewire/destination/index.blade.php +++ b/resources/views/livewire/destination/index.blade.php @@ -14,34 +14,30 @@
Network endpoints to deploy your resources.
- @forelse ($servers as $server) - @forelse ($server->destinations() as $destination) - @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') - -
-
{{ $destination->name }}
-
Server: {{ $destination->server->name }}
+ @forelse ($destinations as $destination) + @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') +
+
+
{{ $destination->name }}
+
Server: {{ $destination->server->name }}
+
+
+ @endif + @if ($destination->getMorphClass() === 'App\Models\SwarmDocker') + +
+
+ {{ $destination->name }} +
-
- @endif - @if ($destination->getMorphClass() === 'App\Models\SwarmDocker') - -
-
- {{ $destination->name }} - -
-
server: {{ $destination->server->name }}
-
-
- @endif - @empty -
No destinations found.
- @endforelse +
server: {{ $destination->server->name }}
+
+ + @endif @empty -
No servers found.
+
No destinations found.
@endforelse
diff --git a/resources/views/livewire/server/destinations.blade.php b/resources/views/livewire/server/destinations.blade.php index b5e8111e9..9d8a2b437 100644 --- a/resources/views/livewire/server/destinations.blade.php +++ b/resources/views/livewire/server/destinations.blade.php @@ -29,6 +29,9 @@ {{ data_get($docker, 'network') }} @endforeach + @if ($server->standaloneDockers->isEmpty() && $server->swarmDockers->isEmpty()) +
No destinations configured for this server yet.
+ @endif @if ($networks->count() > 0)
diff --git a/tests/Feature/ServerDestinationsPageTest.php b/tests/Feature/ServerDestinationsPageTest.php new file mode 100644 index 000000000..3a1fb1790 --- /dev/null +++ b/tests/Feature/ServerDestinationsPageTest.php @@ -0,0 +1,107 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('destination creation modal can mount with selected team server even when global usable server list excludes it', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'is_build_server' => true, + ]); + + StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete()); + + Livewire::test(Docker::class, ['server_id' => (string) $server->id]) + ->assertSet('selectedServer.id', $server->id) + ->assertSet('serverId', (string) $server->id); +}); + +test('server destinations page renders when selected server has no destinations', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'is_build_server' => true, + ]); + + StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete()); + + $this->get(route('server.destinations', ['server_uuid' => $server->uuid])) + ->assertSuccessful() + ->assertSee('Destinations') + ->assertSee('No destinations configured for this server yet.') + ->assertDontSee('Server not found.'); +}); + +test('global destinations page does not render per-server empty states beside existing destinations', function () { + $serverWithDestination = Server::factory()->create(['team_id' => $this->team->id]); + $serverWithDestination->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $serverWithoutDestination = Server::factory()->create(['team_id' => $this->team->id]); + $serverWithoutDestination->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + StandaloneDocker::withoutEvents(fn () => $serverWithoutDestination->standaloneDockers()->delete()); + + $this->get(route('destination.index')) + ->assertSuccessful() + ->assertSee($serverWithDestination->standaloneDockers()->first()->name) + ->assertDontSee('No destinations found.'); +}); + +test('global destinations page renders a single empty state when no usable servers have destinations', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete()); + + $this->get(route('destination.index')) + ->assertSuccessful() + ->assertSee('No destinations found.'); +}); + +test('adding a discovered swarm destination stores the selected network name', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'is_swarm_manager' => true, + ]); + + Livewire::test(Destinations::class, ['server_uuid' => $server->uuid]) + ->call('add', 'customer-network'); + + expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue(); +}); From e7853656c303710ab5366687e503ae22c228ca76 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 19 May 2026 16:40:18 +0530 Subject: [PATCH 026/122] fix(service): pin image to static version for open observe --- templates/compose/openobserve.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/openobserve.yaml b/templates/compose/openobserve.yaml index 93239aa19..295491a20 100644 --- a/templates/compose/openobserve.yaml +++ b/templates/compose/openobserve.yaml @@ -7,7 +7,7 @@ services: openobserve: - image: public.ecr.aws/zinclabs/openobserve:latest + image: public.ecr.aws/zinclabs/openobserve:v0.90.0 environment: - SERVICE_URL_OPENOBSERVE_5080 - ZO_DATA_DIR=/data From b64968d50359c38346b90a17fc136858a34086c7 Mon Sep 17 00:00:00 2001 From: toanalien Date: Tue, 19 May 2026 18:55:11 +0700 Subject: [PATCH 027/122] fix(templates): pin image versions and fix magic variable for hermes-agent Address PR review: pin Docker images to v0.14.0 and v0.51.92, change SERVICE_FQDN to SERVICE_URL (generator auto-converts). --- templates/compose/hermes-agent.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent.yaml index d0a7ec6d3..6522e05d5 100644 --- a/templates/compose/hermes-agent.yaml +++ b/templates/compose/hermes-agent.yaml @@ -7,7 +7,7 @@ services: hermes-agent: - image: nousresearch/hermes-agent:latest + image: nousresearch/hermes-agent:v0.14.0 command: gateway run environment: - HERMES_HOME=/home/hermes/.hermes @@ -28,11 +28,11 @@ services: retries: 5 hermes-webui: - image: ghcr.io/nesquena/hermes-webui:latest + image: ghcr.io/nesquena/hermes-webui:v0.51.92 depends_on: - hermes-agent environment: - - SERVICE_FQDN_HERMESWEBUI_8787 + - SERVICE_URL_HERMESWEBUI_8787 - HERMES_WEBUI_HOST=0.0.0.0 - HERMES_WEBUI_PORT=8787 - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui From 70c187ea40536a3656c3b80738274698299db0a6 Mon Sep 17 00:00:00 2001 From: toanalien Date: Tue, 19 May 2026 19:00:41 +0700 Subject: [PATCH 028/122] fix(templates): add hermes-agent logo and mount agent-src read-only Add official Hermes Agent logo (256x256 PNG from upstream repo). Mount hermes-agent-src volume as read-only in webui container per upstream recommendation (since v0.51.84). --- public/svgs/hermes-agent.png | Bin 0 -> 39138 bytes templates/compose/hermes-agent.yaml | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 public/svgs/hermes-agent.png diff --git a/public/svgs/hermes-agent.png b/public/svgs/hermes-agent.png new file mode 100644 index 0000000000000000000000000000000000000000..0d4a8e82ac5693f10fe763ae398aee596ab8009f GIT binary patch literal 39138 zcmYIQV{|4>w|!zenb@{%O>El}OpJ+bOl;e>ZD(RXv2EYHKfZN;)T-+4>UFxS`m8!< z?_C|PC@+BkivtS)01%`k#gqX6knbi402=bU(seBT_1}?`vV;hrdK&NK`#{W0Q|gzT z9DwG#4GjPZwFH3w*X6t5d=~%!EEfa-_FaSgcPGDTFsWrO7pv#3O z>B2lYI3|*m;F*4U>zX3#$dIcaBX8SqKyvKh<@Gk->U6)8Mk!iAc*YPhLPCG+1z7&M?wedh*c2Y9vLVD9=Qp`051*P!A{N6AE$k(oQa7ZO|j$v)Iu@8^&R5#vrWD-L-j)ts^rebP~VL^N*kj zj)Vi_+QO7$2V2nyk1f zUfW&HOm|tQKR@2ksufo2%@&D6@$zZZ$_uTQ%d4!GCe{Dqaw@r;Eh(+C|CVm;u$2!o zEKRgfGxS72t=*Q8QF&&-{%>63=OUT!Y85QdR^i?5@Y;DO6O;Z-98OscMI>1rR;F1E z-=A1bZ?su`zQ3^SwAilykj~uixL9wY{R}fKF3<%lvNzRjlg3!g6$V>ultOdam7**w zmdhx|VYezpC6`XMNL>cneP z0WVhL`(sx9gD_T)=iC8+&yObqwMtFqRnHU}HEA`$Q!4I(%`VTDwa=?RO->{7O;Bje zDt_yKVbCbYuaY$Ml@`m@(8y++<#ZP7)nwF=M*E`A-$}t-eSau}kG&p#PoWn7$!I9p z0=LCQLbhx>1l*5OhrQvlviagMQ^tsJ{k>zHa^ZoHa;GC1(*v@-zn=E;pZVgEQZQd1 zPdTz4Bv7AAm0ATq81)O2_xDS+8h(`pgZT6cD5AI{RH#=ezk5Gzx|VyjIo8WrY*%Ml z%|Gg@^$5iItBHPv^)`mVz{UBU{N2!nLlLs|n2B5B-gy~^x8G8EF(y$Tne&ZKhS8}v zrIvk!ELSO!ua7yjvNXU?8%AQY{D(ES23b&_2_Xb(G90BuDiIZXG2p=jeupz!@_f;< zy|2;jTP!M-j$dH`a$a5^?Lw51saN=34R|9$y6x5#{j)@nB zuD5+P47_fFaUMnpt-f!~9x7&y9f=qGxKDVG{wb)tk{8(iT3#VcoQWkAfUfbNLwbjR zwe3pMlZN^1_H7&{2dm^(IKmef@+s&BZGq4?x_|YJ2QO<;8K2=aH{DOg=eiF{f|0&& zY)01Xczw7>7+QkRyRp4n2J_t?6r3XLqZUvSPN7a2tCEGz7)(*K9<1+%pIRA!M6M0khe z0ZnGovTBOHM;g!L%FjN$$MdZM4!f;@k2l*UyDI^hl52#gCoy<(>BL0(K-mpLnlp-S zDP+lk$TYao#FyLD=pN6@E)8!`F($)afVIRA9hlo^#tF}F49?lnKnrt_< zCDUSgejmRDVOnotkOW+JJA~ARvekmVs{O`e#rXU*5!4hsN$kzRX8m>icGaQ4X}2UR z3M$XZSDaU^S(krL_{NkZs0i|!Y7#w%#CPqN&1M|A5a#OGSVGjCTkL&a43G5pi~jjg1!EQ`7b+&E$>EF zr8@(m6_E_>(ECqX{rpGSIeu}J%bl+caPfYkp@?cJx{l#itMx^%=kqgST$0Vy*04gC zRXT*eRVJJqw6wH3-mfPm#)K?ra)V0u&&}($7?8w61s^S}_kX>eM?uhvkU_u&*r8B; zHKoOxyS(2Xk(Dhgy1zc(qEK60VkTkGC=7GFg01F@|KdceK!b(nQ;~44MHTEw-h!8l z3Q?mOT?6}OIW4NTJg@l6^u5A}&~y?xU3h>fewA?VV8DoNM6|DsyJ|eRQ(i57SN)6a?ZZ zo(-_|=pkYH=!Fv~#B`aK{h=3R|G37Be}Rq6(S6e3S8WHv!RR=no4h4__i2n z(yH#0P{dCV13$xi#rH79xecwH(p=p2HKattadi6OuvS{lF}j!EBbb})a z(@k>oBzBa4d6@zU1O#o2q~za3G|X^IYT6+GLOoOv_7hAAYy=b`1jHu2{DDhT;ae4Z1YTzzYQ(uQ#Saevrm7!NqDcH)_RKIP>aCJh0XmX zg4n*2#;zYY4MZo~8nnB9HJQLAj=9LYZo(pi)>82E&Q{ttwmY4Wr_k^S=rvnwwZCzY zPP~23jTnELwqH2QxFpO%nTmw>^9~dfyf!!Kx+M*}@xJ6@n~M3F1>3GM;Z5*Zaj!lc z0JK)l%Zmchs`~W`Xb~1d-3E&u&;Uinx3JR>msj_kf-H01;N~xxt96IAs584)LSb4A z>P=9al>$snQ4We*PZy#zwJO*v)esN|9Gcu%q14|R-L!f<(6~&oFPCNz;njP%^qJod z-BjT#na`vZFmyi~*O6N2r|PrMFxMQvBH}XyQ0$m67f|qm%EinZTG0WG6+wIu9z~lA zSSM?2o{w@w$PCXKy}|&KObB`i>R&HMIpSsu(0wawT5e}^V5&xk1n!8j{=&Hgb0m;j zr5a^W1a8RiEfplgGDC3k);zS#3s4fGWI;kPk`HH#YT`l&*Qv2vag(W1;+wZB*)Q-Q z-x0bCbIvod`~BUsNz7OThwo>=`@)x06)mL@TZ0K%=q6K+SG)|PnoKfXoW?Z4pAkgg zLv_EG$CG&p{Bf)!k?-1ovX=|^9y3eJ{|Phzt!9;E0M8`cQZ>$S@+R_m-AS#2!rgcADr z(Khvn58UA&%fbg$3R#%Og)kZRa2i3}r6VB=9i!c#rS`qN{;nyDG-CS~TYZSHriBFE zi$#8NA%3eovNMRLpxOw14k+BQGN{(YMu~Um92nq?ee&Iplc+Q_Z|Nxb>D7H!-Jz;e zN>Ku8!8`Xmrw50mSIqF~>6oDz>^r{!rSnVDma24M*-_q#7B+S~w8s!?F| z1IP6VF550>J3I8)n51o=QS7&EpxQr*;Ro+#1WOP>*nyCjp|pOc#izBntto`}TSTt4 zHRj47Mbb&?U2pg5p#&bKHEcIx#vK+TwP90|+O+GYtTotj<=^M*sfPt>2l!+mS%&l>iuQ-z-yYYm z76Os@!A(6{_|rxeH(bBG^R+WZ3yDy<4pV=g67OeO1G95_*4Tf0{`#Q4N9XWo=E@bE z%wp8882n6nq=90wA&ih4poxvQBJyT%2#U(mBNiB zfEoQTd096?ppitQ5zr8~jG@+fwp3NY5O74y8yim)OW2zLqO9zlM~4x84bM_r!r=C_ zac>%?W~jM~23MU&t1iNCxl5*3B5zYhc{WZ6*R_~1K z4or3gg|?IdvCz%HSQlt&onpsQdI`1ngZE@#+!U|kXt`F6+kRB|UKgat5T04(=svc7 zC(QGP>F$RkAQC|)F*)U)B!K3aU%hhJnt2cBtG*}S*)sURB(IYhKB0RdCbAiRxC-d6 z#ya|0`%!AWHsc{_exrV|`6>@zL*)#BA_@;L?L?elsx3}>0xG$eGFMN^vL{6+N^z$g zeu3UZA~miCVc(`$PhS|5ri$&K>my!^6(vJb8?E(Y(ifK__Y^(ID zqv@=QZO^CGM6V)<;cet2j-D(U+vkm2g-puX z!u`p-sZOsw%lP?Z{qB$q6BoR6A1PznFIyltOWTPm_G3UjmwvoW-UoT9C}-fLB~gxnV3{2qCSk$!bxZvFA3Tz!yt?^HmR65zEJ6**T7cwAZP6N?iEFQ)WLRc%1w1 z)Y-!NHTWC;h(H*5yF3FqqT7iwVBsnIc=?Va$`a)Hq~tOHZSZ6Tdrqq6$=@=@okP!9$$ebhz5FbwAV!li6 z2r_F^P8!1n3LS_j<- z3O$rj+yZV%N zI~kr?Edp6qn;sGNl=NZCswHhUjQQ(~;Fs*S&)Y>`>tR+~{Q30sG@>UG=LXJ# zq-LvB$~tW)3{;E2WTdBC<C>MopaB0CSq`7Qe^+tgn&XR97{Olfewn8=WxS<@F&Y z$FkO{7RvnU-T;UNmU4a1Q+sH3*P*~trwMP~^f<#m&BgnZk{kjr^IhaT@B29k)DZy~ z(oNRgF9IK~R3v$DGWuqmKBk1Hc?CfXVuKnflux}J_t%qJKDn*Kal43l81MC}IF!ur z-#z_2aRh}n}phQeE*$AeCAjKSh!H;z}$lK44eDyM8_3=v_!g-u(8( zydZFdVqUiu7uvKpiS2K;S$Em=$O{fu9SuVvy{~H7qA3BFHECL|$a-Onx~L=divoh! z_XK+gSKWgcodZSTt~b6S8M_r{2FA}Mxw@Hzes^mj`{dnY?o=*SEgZ^aEDR&y%AvOz zqwdhXqO<#Mc}+-L1L7LU-5FTMGH^;+C1 zE7p}9d@i29U{AAXRO=Si@<_44i%j^1cB<5C_?(uN?Bjnq2UNeIeLaa?ug=IR-77#` zbHdM7Fb5mEF?`z3gyKJFwHX(Ae0>P3tB!o8B1Hod^sdJ*dGXYPyU36N<3Ks#i2K~Q z$Z$PHq!|g^5I_~?i{&D2VlMrHR`n1vRPQF^`*i!JD{(;+i}pxzjjnOpRJ zedW(&AbuzkBhqlJ@*YpsdaqMHAB8^8ppN8ioVi~aT-)@tGn7Jz!1UkMj)Tk&GvG-; zf$hWb2!FbwEr${O>=W5n&#Nv8r#HjwsV1cY6VoDa5@=8J-IW?X5$n0#L?)U+f>3vS zhkcajcgaVSc&Pa~UIt-xMmiJSGAZIZg=XtblB-JSyw4`}nrm~km+!RKIdP1qk3O~2 zcC}vZ8h<@DxO=5`hp-}yiDsZO%KSGH>Zc5(5eDlFCqwyjdG2dY{yQpe{!uGn690g-`MPv z*9E{aPzMNYJ(*e;ZGYNr;560LfARr;niuD1hW|&Cd?;xKp-Lx*9LIIVuHC?0K;^d5 zdX)tzMtJy=?T;+&4;G!?-^CF}^{R_5p=%Gva8Ne|o|kG*UXl^-m54?5v<;AGfXL%T z>!HB2MUl85Vn86R6gmMyO6sg6wd-@uVakn#60HZ&}182C_sjF^d71qhy( zAZz9?dO-%D=!7KF6rqzhL0{Cm-~E8lkGSvP!l~<_^Lq;TxD5a$;>b_LA-|rjx$QVD zuOPSPeFmY-ayDkdli+Os^5bNG+KP(2^ZHo(U1MBh#cVI@QGNZ+|8~wAi^|84ntjz4 zGc`75REM1IK6o=-$f;2+_O#)opow>j`ZMZTuXU1cWH83X2F!Kq5o3{7fct}pAK(2? zcVR;|i&+Hp{f6`WR6T0l=P&WykIknI`pbTQi)SoNkPBKhBH68wPd-Sgwk^i4LeGcu z1F8gDcOkaIa?NOPMbA4VO=FrL6vO&Ngum7kbOPHFsS_D=btm6=N0HQ@GLa$uAwu2u#a?Dv{l!6AUh`bPfkJ@OA-q1u$ zRGMhLXbOBNcwTyTS2&4-pyJ5z=J;2AL|>mBSEjpnZMdUPnAyI4C{X4fw_YbjDaRv( zz8g2u2@m}CEiiol%1;~`&0hmFvXOiDs!5STOxS-YX>*}sK$1>R_xV{@#SEyE!MWd3 zp$14Aolw=k3o@0To3&o)G+N}bRULxyjOXmM0@l1zf1o@t{(Kzz7fr!0BD7= z1dTdJOPtic*UFLmPwyp^>FaHu#a}FUqOl^W{N~LedYox^l$rLO1l*2=qVS2Hs<9V1 z{fr*Rvj*R*EBBDc4!6eRBpv278Ggd#$BtHxKN!@w)xMAU;|O|PaDI4*7R@i1!djgg zFk}C4dVbptH`!qY)8LNV@(O1(S>p(Lq|cqu+K*!XFRR$iqwGu@=AGHez?Dm14@`MF zpJ$`m^B7UExpZf`uQJLU^i>!{+w5Zdt8qdzP*4+t)^#zatDdj!?#uSG`*-&{HnN@l z+=mT&K!DQiFUH+%T4%aIf$9V)6$7S00)$*~D93K)dIMF&yZqF~XE!|8j zJUvmTdS%n)^9eavPi}Zp5^Pv(-*K>O-tjk~eY>6)bzfL9_Y3xAk>$LSY@{?lM-mVG ze?3eWlm?KK#+kz#RNE<9nq~(*;db7n6<>GAAVH9H7|L#mBcz=g`tIb{?v0;EIS-D* zax8J zgCWEDK6`(CgekI3t=}4~PgqttQhC^UisLFLGmmWnNW#MkSv)RgP2U`LD3LMR)yI?I z>?|329?Rp?H#}2Y)yZ19SzP(Mrt4>7eh30Cg;vm7hF}~3Hx32bZ{I9Do}8`oQF9xT zUk3GV({(!m1KsCMObLu3hn*a(Ex^(2u$%C*JTTM2r!<|tp^wp`nmep))ogltN@8Ds zxfe(rXy)4i0%mp{OWfWhbLnT<8tkX*wUP@gT&rJAzupjB8X|&|8Wkr%F1FSu{)3my zI5X0*HK#^_p)r)5n)pp-=`vwEFN%Z4g5-f5cOG?oQ&G%xcJokta_(#7Q3Ej7G;2~? zFtExFxJJ7fzg3{k`czY^!Np|5r5xAVx=ur+M8i~bEOum{jtgQ!lEm8}Z}q7D9z!0J z1q~gM%oWLC@`H}1el*)|@<_^N<3^1n6a~X!O1~K*%GMY5BCr3MV%LZy!lcegV>Lg8 zB^ZslX?5#-+Ulq$cP7~$-CnP3PIEJ(+@}ULgjlM2!>v$DmSqu`pz04EVG#}0PF3#* z^KFeG{S~T#OMLcC^8^7UD3n{nL0PfjYf(y#fF#e+gtg3&wM1FPS*#k&<{m}Y-k7J2KW*3i9{(64phPc_{=;h41kR!d4ebJfAK}@Fm4|tZP0x_G3Jk z1ZR-_?g>8a3FR1gDRWjO9k-9+sg#?HL=xAzrkx*! z)lGR`?)bdfxG`~SfW`ab$$}r+`4#d#4eJVz8u&q`2|$A-PV#C-&l!k{sXk`=lYLXd z^Ft9o=0CR28MQQB#Iph)I3AGnupL(a73I%d6URn33M%>=zA7exqD_>WAA3{vAfwjy zrzS0rTfG)I9m%Jot?=Dxck6O2jecF3U%PrkI!20c%pB}`ub&{#IjcP|#3Rj;I*sQGbUf&9 z5sH!sxe>AscC!+$UHkoo_;Qr};QC zYCo*kYLD_bf?#^k3F+BTAw1mm4L~YFms1yRfjtQ#ASy}QNXcahVvPU==O^c zGAH(Q_7XZ~6Z@B%f3Wk8zB6CJOZ=@6zA+wh>400)vgsW}SX}iKVv6z3{WaZd70h}p zlq`E3Jwp_dR^iU51a>v~rik~83n_s951B;{eCE6UZYyR0twD`#mf`m5{B?FGQVm~C z3k-#p3;P4BhKhuf%hJPN&aE&_iV1RqBRed%opAUU2wHo1$?^%k9*dcWUamuqsj&Ej zaBe(GE?eniDJCU^m_b^>6`TcEKl6H@EJz7j4aDqrL8G}qdm;1*zVjifg*kh3{>jQ z7HzrBOOtX2o%Kx>=H1~GCOL^bd{_*miNyGF5G%=s^I7gWPj*(qNaLzNY2|CWNWcv_ z{qD|q0w^dWzFxBbq#uRyJxND}M$P34Z^m<7wHO883)4YJY;u=eG5$1DF|nY(2kP8- z8*0gq>t`}qp)i>ljf}p!TJF4{k`)Yq=q$1FN{J@|V_7g4|3-C`2VT>fx|)_kAXOJeA`o^< zC|;Ht$F4V26ddj|{UId)o6_Z~>nWlRp2=TTW~H+PN_VZX!2(x02tx_ESm|uJN`r~? zSG=db7Ujs5;BP)Aq%(7JUMxheUNJOkKIz1{qm5*G9WV#kjcZz!oQ+*mb_7VdLZ$dD zY$x@t_X%XETrVPKaUGD>B!S)Fe*wIooBj;6`9cg6ys_=kg@^tZ`D$jIv!NtKdrZ%p zHTeQHeiQWRi_Jup!_d(LAu4bX!!`>n0{+#*){;U0F8BDKr#Uy>5;SF^(?=ibIqC34 zb+vb#Av}oh40U#XyA1s0+ zK;n5WzE&>XPTJF4&!&ks{F>?-i5zT%M2+)HTYw*y3ebelAIcs}pbR%p&jAuLnMR?) zfSUQj6y<9(ae?@!%yuSpl77X(AfYXE21KcfF*WkQTY+U;ASz)Xl~INJ2Ki6z2pP}t zzg23^<9{4ku)~5uQbJh$E0jWQHvIHL=^ApZ>eW!>T2uBV6%0rWWC2x68k2L-q&r+f z7kR3Ks_QSGF?pfK}3IiXBsW(y$4YG;AJdT+I zRUy5LBRMb~Vjb<#>v)VKfgCUh#IRT)Q#?P)EaGN*!q>~>N@cPa6{QvpMNo?+jYU3m zm5x*qYg{Er$=?CWaVO?ZdeTVt{Rv3r&Ea#8fMGz^crh-E2WRvAB@3|EpMa3M0}Pe- zfLlT)$YLRdh{4Ax?-)gy#IiZCxAntdRPMF7i^G3 z;q%J(OR{9}NcxQs;?x6g=B8RU=tP8Pm15_iEhxwT91I>Nm$Gn=qYv(M$45!9ER3cd zCXtaR$+gmh^v60qZFabhX$dN@mU<$p=+}3_NLS@CFpZF#rCQxgX4o9HJ%$o`Sp*Pc zv4XGXX6$2gd=O`LEf&0g9tH#Z-1V;>o} zsE#7O;*dCv>_jlnS#JSYWIV*Fga9;2awTFe`+M_*g6lK~!e4#Ao_twWs-_3sTi{7( zrxK}E9uEC-|8^z3B$Es(`X+_R$^iA>$4asjNxNA4EDG-Nj85*Ef#01G=TSyN5G6P|N*2N@xW3$%%lU#5-E8`i=R!?9_=vNIidS?t73lcQ25X$7*x`p zJ+a5WEymVHVJv@qPI10L#JEyhKagV0)IuDOhL#{Dez}&g&m_|KCacu+?+S%&9d>4& z=XE132D&tp`k~u_&^gAu(MlSIXFrL>4zjU?dxY&sr{2S^Es=&R;Jd{4H$HIK0|DZq z*?)-IIxY7oq+~LlWCxx%LKE(N3ztbn995kD{sBN6^a=3Ez<{?bjRBgXMo{%F&zH!#THHbywA!vb zV(NPlPd?&5JvK@}X@R-CT9)Kx&McVTW1t5GokOuGZXlVPE%qi;OLRT&%EYt62bVVe zUN(ed(N&|evuqN!m+pH07g^tAk3skl_YUHd;^yZSs^l9DOBSBEgaftoGeqgddKEs1 z-Q-&+S0VJeg1KY|X!g-CaQa*G*?a`e{JWL@H*I!K=~IvOi*n-TKLT-oW|+4aPoo~9 zjXo+;5p*qU2>36XR#H75Z%JZMCPKY%33Wun@=5~LS0V^rFBS&}Jhb=c%t5?KRVZ?m zGjFV%UM=R%^~CzezGi|DwwzKc5e);1uW!T}y%0F)233F}Wq-wQK5Y&0mm`=|LdQxI zH^hpzYSHKOW8!bS%xJW*j-s-!fFABCt<9 z_Hhxs_b?a$wp}s4PyCm_yX&l@c2hK%1Jm|*RNOZ@F3ly;REZ=yCZXq+H*X2OCzr{l zBs?45V#EY%LrxfO5;i&$Ft~=F>`nmg7Sl_n7PJRqDnN1f7Eek)`1yLq#Eg~&*0QzT z$B*&wc9LCgKBKR>s2{|c!nm_#i3aKBYj1-)N@i#*Z}l05Zipk(1W^zv%kB8+`{f-J zF!g3w)h3OcV50ChhMPE5f=MJX17a3wyLHS`Ym^|1V%$hm`8H@b1|Bl@2sT0%u|o8b z?b0)Soq*ZV)V{ub`z|d?KpFhcudM(GIE-e--3ZUOM;qAnU?Q_X@4Eg+7*!$nnsa3w z4wFOp%%7ETOcdPM8$HKE^GR6fdHE9cT?d1t?9O-eEh{1)n<68kdiI|XsAmTvpI!;Jnn3t2LFlqh5__FyyyT{sE4@OPu5#fyBW7z}nj!|{M=<{tfjG>>zjMZV6%zdZ|{p~KaPq5UfZmJ#(W%64Lt2(8< z`UnK_yFsx$`aFMz8fyAE@-s}$zgoI%xDblN5dV`S0ZGXTC@0UOjXqb>A6Ag5P2fRx2y zRn|#nu-29fjxe~8SHlx5(-5g&lS;}CCwc!zA0r*aK+L`_jX$CktvVlBD-C4>-C-73 zHw|dQLBkmwvd9lWFa$068HY}LjKf-cbiGbh|)BA0I z3!d~zls&G(F(s!HZgj8-vi3lfQ?=OZ(JP4}4HwYwb)1+2E@}}Dmo={j41Y``6mIURuFpthaE5RG0zg^JPwy z5GBk$31fqYe{O?*Q5sHgN;i8eCiefC8(Tdx79)uAhR+b;YBo|8J6HNwpzITzs0h%l zlZ;0k{v1ct&~r(8{d~I$5coL1aMF}BYOBHAf$98W{})&qxdU9*(dmb|UVj}cUY z;P;LIo`^1Nb_CvzSMH!0w>22m&Y0VeCX?fw+GA|oSXA)drGImWSQ+B_SSQFRnp7ul ze<1N(!X`O+=izdl!9wzVL$(C)5UTYX)@UmVLzO~0r4ovb+c;0sJWZkB+zIjwGlVAH zULp>tcO=|?SilwHfgPN~Fx`?MO4PyB4uyGjpu)=%Ac?B+Iu=6Pam7XM949Q8@d0!K zL}2i|7nI_yVcoF!TOuj&|mL zEKB>!D%2xzttkbu!I+9%7M$vz;+mEppw!5>`2Rp?HL8296giFuN%75o(wKY*p7D>J zOWq`4PhZLSp+pNcf2Nj=h@wORzWn{2KN)M~pv!MPcj#Xf%!c;e1^GeIelTcP(gSvgX9c0O+)#`#A^j=qZ+y^xZnS&iOj3!GBF0 z&zay^PJ%^o7_%$n7)DYrUtzRE*MD8P-3<986w+-~XLbCmzqqjiOtwhbHyloN>ht!u z%I&8TQTDiAZPjavY2)F+@DsS99{ zhNM@1Yn#i&-_{>n@5U>R3P}MRV*8ahO7Qk}oF>E0JE$2B&RPY-bEl2-@-q`w`ZIi87GPoLgviY@wgJ!LSDo*+?Cz4t_3!CBY&PMU1-#eA_Kx zHX!zA(xgnbsox^*azth>*a=F>PN_% zFUvED!`JFfo!GC7mxD!qDAX#nzR5<-nz0HGQ<1jcL|IZkNPNU&mH^6FT&fuWFET8m z_K$ZNL*8qvB zn~*XJU|qU6T#q61&DBw}3le1S?mXq6t^TBN_Y9-Zzqo$JTD6+n217}fw~Skq1@~*z zLL=^(zKb_vdj$cAAAbabUs;3wr3c-!?P{)4417Q|ZmRa0t;L$`Xq+8D)?p}4ZJEH0 zpck{7EXtC(Tcb!P`L6E+*Td#&Clj}cPN4l9-w;FadDx-gyCsDxr4!^JK-})7#Ox8A z7rREc0%nKZ*6f+TBLFP7Rbo?t#GG6!hIjD(gu5~0+eq(~&wjW)Wh4%l{HV_!s8N2S4)oN_JCabLDe=jv04b1jt(o1;$ zNxIxXAAtFI;CvTY5y_?SG-}wTAx?0(h;3=+{cBR*C9fPHitZ-Zml*>9F03V z!3BN`h8{pl%2G?vUSPK2^bZ-J^OM)5asCMN5xCd>u=!E^fEz=2XNWZ?OX>Bt*`kaW z1Af(hR_**F>pMTJF;L(;QoQF};v}cU-}1o7w52KDGYu95W7>%hYaLIJl7JBvWWtrS zAP4IE&WcH{h& z0CMJ7+@$(0(x@4&9@JXQ#lK#D%i+=1LnqM@WruupmElHvC*3mNfPqNDrk(}n7p)|Y z|2RYQk!`ruhue6c<_iI1y6aHXB5OfhFZ&7L#QTR>Nt0Y_`N#H3?<3)vy|z+9u7PgS zz4Y1mT+`n*1e5?NXiX*`xaYfUn*u6mZMvxEDcf9@WiQfFz1c*g+HZU}K%h}^M(@Y& z0MXxqxFpL8%?Q}t{cLbEDqG)9KicnI3?neXwf21?Oyd+`QdLE>m|8Fhe8oDTcQ*Fm zq&Rsx{>Y_t``M=Y?h>$XJE48{la1FuEP~AeljTAEtwD_Qxa33;%+U#7HT! zPBX@WgCqAllI6uWJ<0rna?}A`HpEhONl3F<=oA1YjavH!4m;Rrwa8=j{{D^{nIrl% zu=RsPApX5EQ`AP-&7-~tv4c{r78Ke)-JLvFB9>}*pTkc%>?jL&+Y<1isXJAmM5L=I zjn0Uo-onl#BpNhRp9Y0uQY;z(-P>VC)&;z#-dmfD5yrjT^mJCx*`!MP8HfUYiAm^v zAIEf3->61`1ZTXhf}R>x=jLY;J{oX>22xxiiggRJD^BvwTjMWCn0BMYE%@wTCI2V{ z!E41qgOOPACcwM`*QSn zA{gjMXTk0%;7f8NE{}Hor!rcEBInBBfbt52n2negQ1Tu=zezx)p$?%WkwI1{y~A*F z4++W3SWu2_qq4(ZJKr4@_gNPo_5p?6tta0KW$#H9PJzV-F2%bX4jUYbJ;CyeHk-Xu zP@LCX$lq+a_k<9u=R&3m;XjoLZ&GU-j^Zxgz)}3L|4pAJ(tM%7wS-9dCm+Yw8|U6CB+_gA*m4~BOY`$=(77igdft!GNIZFAyw~4Oc(qV& zyh6xH4O3(!E5EM__^A-_4+{A;w0P+)A>2YdOx^ol$Vz z8?o+AX=}o$tvzmvQVMxu-DD_Y+oZnyshtrp>vfW^%g zI9a1cYYUcA^o$s32dE&i?+)=oBOC%87!R@@+1|hwTk6yRaAi!UZn zZ0=yTM(2}mjATHz4*YoYqW$j1gQ6yvipqdjF+J9(;bjl1t~RhT_sV047Q8$CqY zXYcRNkQ3l=$1B02m?7yY1<^_ki1155;c}hU zwT5~G^ra+{4v5_Hwe1j&0VmgMPJA&}E+k$908dBBFSl{Y#dUA!sj50x4rp;0% zQUnyEicR&01IF{!v!^Xjg_s;BVY~J2&Bx((y(Y&Z+l|(`0vpQAS=j*zUCoV7Xr^sY zd9jD>vn9*X#ot`jv$>kiEw-C%mFE`LXl%&g1qf-cRq;xUn&$s9IPLxcW5~LugaFZe z!u}`!ppuqW^sVTSS-{_C%G?eO-W{~kzW3h{X>OA#0*G%R-Qf<6Sio8T_b-3~?&3I7 ziQNt}L_9}H@x?+*G>HA6E}+z`XbmGxe^Fg}21a_9NG@pt!%7RTVF&#AcBlzoYj?h< z1*pbF$5oq7DGWg$$|y{++5Z8jL0G=zMPLGv{9LwtncBQ*3(QSbkoaZGm6x)~A7}tk z)gwIIQAtMlV-?NvL;Y;tMZF?PX>3JG!@>=%+|+}7fE`FJuno^Y|Gdb|+us$$`?(;; zSFc(P<)CwILORK5seODfM^YRV2BK;)-If`uEIFkmHs^`Iu*X>&Idx`@mqGynu;eCcCgLuV6ZbRH2F#Def`bX>g%t+mbgSBePH;~>@7Dy z>E*+ZKT>1i7JW5H@1jLvj0Enl4u_sQ<#HHUZURIcTWt@+)iyP~e2>ug8Fa zTDGjEXYL+U7BLkG354osHKK@+Ue+ImnD1m=xeK9v1ojCL0hMM7I&fb;0*lhQ7z@wB z9|0$6s({~)TQinH2clebG8J-(0K5kzPXgFzN@3*{bn4Ve_SUs()pS(BIk_FLJRWQl z3$unY;;+5_n&_B!z+V5}d+re<7hI<47Oc;!uZXVuAr&gBMvaaYIdn1_xflE%rmpF9 zqIj|5*w>eXAHfQvPbz7fex`eoQqILfFXuCk(cYOhgs!F! zh|nqlNggP`@kp0tSy@>Q-c2gnPagt=zy6vP$e!QGe1kwx23&H90K!|q!9^z3jW^yX zXWyX#93Iz%a3B)=#5HMRU55-Ftmw{(W~E!hTZ|d>vC?_JCqbvgBvtqBJ=8gHJW(D( zPXt;uq9>XDZw*v)=g)^>$m(i0Ci>7{eiri2-KO0NR+FGfM)z6ZF{pRmeFv-Yp|ToZ z()ki~c-5-1ZD8Gxgpo=5qsv|0rJ51{98}uU9mqsG4iPJz;ERb+(cTUH0Gb}h-aNrv z{3ZKuzeQopn)MePitHENlT?03f3*sa<)nwh-^UT+#BS$euhqO+bKmKOJjd}>qCl=I zx%Bi(`wGt5pN2{5tgLMH!V51bYK2)ihIO>p(>$ANq|#}+3}iDn^HeA1=r?--iDNlgwj8z~^o->FCF;Hulx@KSy%h0;_%&65ynF1Hy<>+?DjNuA^ypD)B35=g#G{=zdGZutKvH37=+D21feBJnEX%Si zVCrWtA=*9L%%3;V7oXd}rFYDN5lVi?DJ@#ShQHGTaH?9je*Jp21U?co6_ShqOfxx1 z75ued4073@m@QY#i!Y8>G*+u;u+o14Nxn1TimOya@vSxxl-FQ8t%I;wyFH%0o(Y-xBZK<~Fz;Ql;bm^f*Yy8MdE z)dld9^UXKkhzBLwS}=KAh{`KhXZ^fl*u$vJMj3+x1xAbgH*0krLubM)mJfj zzE(8Wco9rP(eY$x0y(lt=z)~WNhdZ}jX=53<{hoxay4amL)ai3Hf$KSLCxU$d~MwM zTdRH%^~WE7C^9s!xbg~6Im4|ZCL1CM-)7jqL+oNnoy0?-#Lm%Qj4D;Dz(R0yEE1iB z;<*|QRolP|5N$V7SSMAmVEzI*o+V;U7fhZ!IZ#x5-zHu4<19{45XcB3Sb{(lKym*t zKy{x!y`>D66Ybael1ns{W;6^gMvZ6Bg90t^#KU4x_I|nY4}278oWqJ>5}(J7AFnUH z>@xlBH{bFxP)}3;#IO7J|EH%;nW~{#sN1w{BlQN;QRDmnfPuCR$@&7CDT5v z_qu4Yq8dm`H$0}H?)yex4SV-!*Cq%sO%ov|7-F@x$F$4q8s0`G%*n~opiqKknzB=I z1)oM=(5{_^?uCXU4GoDosk{9T1+OTa_2g4e>6$fbrlejkz4VfX0}~CA?Udy2?mG&N zK?Pl!$uW_aKUTsn2!y&?lQJ<$K+_nn0cVA=KuQN*ef8BbI~$5MdOoDqAW9oEuBkV# zFTVI9i;;3xty-n0Or5Gnj2Nju{q!^4yLTV`-1E=tZ@>Lc!;Q1P^|sqI+;>TQe2b)f z4A`~TJ$m%em8(=1Ni#5gB>yFgmuT3(_uUpw5Vi$;3k^MiU^>VX$bIS3r8F!X>vJK= z_eQ^ddcnd)8WwZ~(OH8k@^|nWbgU#1K;_D*M*uJjF4BnTa^JvygzW@fyoCP+!9Fe` z-JgB-*&5=&8cgG0A4iItjBwb=&~xU@m3Di5hjfOq5_SZT%Pj!9M}Yn44crQlL;pI= zaM5vOG@!it2OoT(o_y*_brPiY`~vf)%sb1$q*mMDj-0mUd9>A}absBExd7_1l@ySR z80YNQ?@c)(w0Hu!Bg?p@m_8kQJY>V;!Ja*PS+W7BJh{&6@$(Cm1(*UZHIT6yJ9ez- z6wt^D+r)Ax%(7tN0`&_RS-o()dB+`HA(-?{z~Zh~JIwfwTmya|YYm7TCpT}ny}0I@ zYt``Ihl>=Ry_S@dXCHBkMrIj3?An1_s#md%8#L%WPp`c8xbfVxk13_s2Hz_kKxa8K zXU+%|W$%G>lpebb86~-+07wZ$TYW5~53yK%>ZzxgQR#U->ZqgiKmTmdvuFS1SOw7x z&-f-B`(zD%g9hTz;5`K6VlSw(a7J zF4Ev;>kZiA7zn2T40RUy%Cw|XDpjf+vwo(&+>R89Lx+G{ngIg_YC{#lg_i#6tFJVU zYjukjEi}J3Cc1`WO$`-h4JV-b{Xy?z4E$r7CRaEd)$AivkKA5A|LimU=cqqrdzIU~ z{G=yUOR(lit;r5RPj#g>N8_NZ}5 z9v*t=Vfvth4~`m-?S$wnNU_-(5`Fsi+q+`2{ww7;KZ>clX;ZALWT7GKp;xV5qp_;t zBmbw#8z5k1(>L98Q_Q+?Vn6V}gL*3viAm3T5uU<+nDxhM3Pr0{8s^3|n5Oa$1}G$c zXyCB^?lA9XX0UkCVqNJl|JxKZUgEt%ze3-5!;N7|RFU#&(4e7)JcoX{&&&GOTW-}? z05McMvRcf3jFg`{{Mu`;g>uklQHpZEOT{y&U%eNL4}ss+?>9p*B}Mi zAFhuP&j&Jbt?MF8CP)PaKQQxV8BhX>*j7_7UQ3lOt>?_0$7Grlo4~zlvu4e7b*z-v zV9-AM>~nhf@ZlJ|TS$BeqVYdSOnohc!2IE=D{BAR>sOHO$za^Jbt@>2D}8;N+_s(k zA@XYo{=b(u;?7NamTyM@N#X&Zdj!~*(SdtTo{zr+m(Q=g_L@k%xl^oOy;_us7=9s+ z@4kQyzv|G?^8&O-rjx2SQpj@=hD`yZpb90`{a{P^kFzf z=+vpReiZzLmtTHWV<6>X1#x~8)(tAbN!?pV>hDYphk%&yY2eQ<>bFT&-JgE?8FqOt zzMq+6iC$+U$h-2z+uJAFBcx%Dz+baw>6Yjo}x3F;GLdn}q;@n(=m9*WM{1`;$o|iESZeeb@ zpf;lR zy;qt{4&wr`6G*#NtCneLSBDEbu*(DUAklA_yL2o+V-SEr2&@!=oZr{`zOGMb7CN7W zw3z1Ez8wa=KSZ`80T0;U(cfI2dJ#^TxVAEEkop0d1L4*{H;`#C1dGj@t zf~`(&oFW2H6Wo!uX6%a16Nhp z$oKrXci!2J*+^w_*RhrW(n4%Ic+|BJ!a1+J@~S@j>~ln*3F5#a+ywPr4HZ$V%)no^ zliQV#KKc+y;VKQwJyKVg4%bgT{j~ny!#(w&_dn1(ckPxGxvQr^8s?i=b>Wem-^uFL z-+t3(YCH;E&7C_}L;ToxKP1`~N*=;%bP~U%`KJD_EZmL&l5P(Gonr&`sb8>EZW)_K zH`~HkCVw<&Id0ro7}}U9^e2Uh=r?{hgksQ^u*|7Ky9;*N3H0(hXO6MV8j4DJ{Yx*s zEEJH-I~w_*wI2glK#6D6s6WIuJdT@0K&c#5h|9qgSTQKxfNB84a=!?_(0JQwPjkkW$U$UMaez|_PHoMh$9jro< z>6*1`Oy-U&rLHJdMsr=&VTSvZQ(CCOgNHccKonG+d~$AV#Ip{jO}xW>@UaLcdX4vl&MohI`E?pKU6o|aD(dIySKU@z6sxY`z>|bdFQLMV6dd$oBh;nx7{wv zLquFOGQupWsb@|8Kmn$k94(6HAy`pKw0^yRNyT_d$uDtfD}pCOR1Qit0$`tv2a>t% z)?0zZ@gaj)J@0xeO$fj=&N|u2A{|97MPW%J7+hIot8YaC%y5`lY9jRKjY^h3?DhbWEj$-obm`07n z>{kaIaTYCFOxiVYz(BE~$ea&>@lXQWZc37Xr2KLA{f3PjeC2B4U3)uyCCm6oG!ag( zP3_yYm!oG=kf{VBoE;E(KKNi*dP-$MKJ!kp9DvVKqc+_+KqW^BGL(V!KHADWvmD_1U$*gr%76UI-F?LslEuunhzv^ohSe|C1ZdJ>*` zxs71gCkCN9c&88ls4{PmEI5JWp3l~gePK;{?;KMR%34x*DiK=+si;q0<~e23WKkY# z4{uJX25wQOO`9GlhGU7pyn{SM0QN?kj<8?t??d<2)=8_s*R2l)+U{r-i2LW8Z@Edm z53fJ{;ST)54i~Dw;1950zkce7BaRGC#KgPatDfN#-&U;%pV?glldnBx>h9^wKGIey zb){xr&O2c3dB~8#id!-=h*J$AUPI!`P_pWnW*$I1)pk#gP+D?Rw?%Mu1+6kz7CKUm z{&Tdt=iYmS!%me{c=}N^RLYsiZjYRMs2qQHCMQ21(;de=o2$D-learOInNNRDx?4c zUHL7{w%-co9PLulg?uVu^5jVw<1#EmD1i97n+`STgu@Gtg>S#lKK)cZ`|LAfB{(Z9 zOMU;{_s~hdCOGR;Gp)RH^$PV03$-Qs+fcmR->qHycT)Z2<-SIFmjjH*z#RKZu^o}> zS}a(wKrLSpc?+BB^6mNOO9TKdE|9L)*}1(jysriCKU-lCgoZTBmMN&$|rjL=g*rL+zv&LS@7S&R)qTb=byx6c&cqAa_coC#Z-ph zFP1M60QC6Po;{YZkGr8{@LhM^b#kst7hVv*w3uy(=$x8F0;xmq)xO&H(M^wSnXyA$;`iMnk)xFDAK-5YV!=gOx|`=H zFT)W)_{7`+OTLkK09n~t(m0B9(!0>zci#nVx8;s?orXE!SVOi7&ph)qGzD)Hu}nv= z6z~T!plB60Q-Pw~+|I3o<5wP)r5os(8D`}bEjp(Xr~oz=#%?SKAM2?s+vJ_$2*6Y` zbltXXD=g-CD+0kmh!qh_J`HhDw)^_)yQsyBmxMCF9nZnrv}pr9@SkCFdyawwk^^ch z0MlhEl0g+mN2U3J$GB9q-dHR{uol;1F$72N7~j_H2p{*-yr}V z@$n4X3(Ax^Sb~M*$Db};u2(R;2}E#<3b|+wrGx=d)qg;L_=}J2zC4sF|KGs|o}%b$ zI)KQQR-H)BmjhYP$xTsP{v(S6NVSo71NU(%Cnra?X34A{O|cmPs95=u4?v#* zbYGro;89>mCELDPO?Amu{u!(d$O^JUoLG_StGhkKfcbGbxGbP09ZToGbEi(C9oW4` z57qmn-g3svI0i`^)9@Hc5-v1kUCARk5Z-@YjVpXw#1D!@TtO0*Yix#Tq!~awF-Pc3W2qb(01UZ8o zprMr^{TSHYj#NHdWTt{a3V1V4BXA!NN2c;)vFvaDV4v7SS~E2jBgIj)NKruoeoBB# zH;kFevUrdxNfmJXjeDRQvAfFGC!9=*U!aLNdocJc!5bxva@8E$)|86 zS*(=-TCd^ zvscdc$qVoclYszg*RCzT8T_V6G~S32BN9!Tt8^KL0NiciG!9H2YBWVIM6cp(Co?fyk7J;TG%4C+A89$i5OZt)_m z@}v%^GVT|aK{1G9u6C{3ev>2|Z}FnV5K2qGSa3c-0HKM=j{y(D5j&aZ+Y}!SftB_r z*m|Ss=l$@^%7lI^4tWC#7NItb~8e1HJhCDtS*0*J)vXDs>z{Ohm3cpFG!rQ5T64@kK~)K5SDs0I!k zAY@iitOop%=Yo)VeqSlVGD zM~<|NE!sM&X_KbV?5d+a{NO`z&3tFKZfXcT2Gb=peFxHU=ldVLj}P5V{qoB%B8P5K z1h!1fZLm7Q8}lZEuU1_ta$3-V=Rb4+>jgU?avW)U;1!=?ix(}CZG-r2j1h|fuUl6>oj!)y4}%DP6;PIPYcB-*Z z$y~K^g(8W_=zl>&ixPv~ph?*6&O3vO8hpnHoG5ZH^|J1)!ZPeXW+PV}@p0X5BE^#?PEs8%?4S-)0d`QpBlEsTb%Aeu+E>_^8 z#S7I9H{75`<8;a|{QmpzvgnYKL6uVq`2Yb}?ca{QSUhLgUw-kWdfK)Uj&9fB5h}i74@$SWZFW0QBkC;NJBDl z!oo!h#pE3QRrm!S2aXSnNxx|ljhAl_02zSf$i@S2z5P~5!6L9Y9d%T7RSg_n?(;Wq z-mI$Pc;}%9ACzn*3NSqvGSMf*d}s<;0Wt$AJ8%pPRuVYK1W8y=43WFc`(rhl1Ij5P z?X0P^#~I}NiBfm%+ga38**N6veHV~G#G=K#5kMrQ5UU7|yUm+7slzG-jLybNV0%v| zHp`bUS2toC5J^JO7Cm@7eE4sH`@p~rzDe#)zCi%%*T>Jnrs^wC1nkhJOdofP>(8OnwOr7|q#a8`jvJy;bVZg;;#3TY_+Ff$VB|+> zZIq+H#!Z?azD2HaWpskU#^&mLo2gGk0Fm~tY>B&-@wDUOi!TlybHyuU6Hhtu#N}2m z?9(9w?%`>AD-j?csQOXZlP6%fgUn>YwGcM@>8GE36~72#>U7_J(upS`er~@~V_dsmpFZYcKO8#Ovcop;{(DmyDn z3Jjn8_D~@Tjta|bolmpsy zSf$GGY1$>L9`<~A0#GCZx1JiIn>KE;NB4bC4*tOhdutiS%7hZETu`WpWJj2>($QKl zfpZ{LknsqB4W|ZRtYfTb9mHc5?)BYp&e`XvLGKMx<-y^n&p}RnVg5OYVtemr{-k%e zkunj{P5|8T<9X)RN>(PK zu3f#x>s8FK9XodjlQyso5TLe4|IV&pz7K?Go*BwQgW zd|~ZXEE1aUlLvI4w0qa?Txa;oE3bGq@{SfpS9bgk1}?bZ0-GB<6Y706 zdMc?xtO+0h_r$QcuH0)*gD$>d2zfUrvC!P?|LrBx9v+c^9jtx7J*1%ATi+=aegLy; zWD6DJTwgAFO3NFBFq*%Y@u0hXhIJl0_wV1SNL z`SSx*O^@Ua%TM}PaS}iPp)C2?x$=`#dGW;;)$ZN9651n$a`e9AnP{@$?YG}n+z!Ns z{Jr-#?OPSakAjI4@0G`;WT+8&$^ZytV2K06`DY#i#dSBstB)Hfrp)b%VaB=N`+437 z|AwW(V@L!j)I(sOaU0O8b!%VQa0^Fq;#5K62*5)6QZG(^o^_sdB9(0zl$^;!tj&+M zlvY`i1Y`t0^USkJ$}MxVA!6oNpnI!i0=euRA^hWjz*s0-wwyZhi1^iJ9eL&Q;V^jr z*#@Su8)#sIoP0aa!W}2p^KKZIEnguqSqJyFq*cq7zW61yZ!is~SQfCO?Y~$xj{QDi z1R&oWe<)=0%(Ks^JMX$P(4gc@epB_G@MFI?AW3Dya(n&q%P-W(n4VZnN9VS*yhtx! z2m1ee)QaURl3IZV4eBduRR$sOxp^rGTYOZn&MTmP$H7oa+PpcO)6cEJij}$R3sMh> zFag;gJ7Ias4s$BRj2@9~j^LHC30O!EAi97D-3Jtp!(7kVF z1t$6L`t|F48v^lLG>tAi`P5U83fpU^U?U{YeJ4lAIOV~d2pcwTNNPDG|4HgcT1n%z zotMnY(Xtb56Y%@G;UEa{(6Uc%fa>Ux+8rxjd!2|Zj{v3Apkaep$>jZP90gJ`FnlED zezA=c0fe@EY|!0z-wh)hYZUe4g-erKp%HH=Nw@|lVd21DH$5Nz)=x;>2*70e?z``(AAkHwe6BZdPK59xqe5bB3 zqWVVPH26bmTWG}oM51qLc7K2If#FU(LLW+d}! z?n7_gcX1#9gKd-=0Pi|RJMW0YkBHmUvbz#q&uk!tFM_VVydotb?B$nVwpNX({T20P z5^9l}ERyL5kd|%R8MA5lIC~*n^!7V%tJ6l`4s)DR;Qnv(f0ZPwDOdZVzo^X?5P!6HHhN3pzTmqKx7*VX(NzVJr z)yrJkp*XUv>{El%Ezm`3=4jX_pnc1aKKdxI)_aZ1@Nr?UaU2N1%A7jN_Kq%Rbim_B zKK6Qe-cth5P#T^Qk`zdXA;xN}R~I^qVM9~U*kB>iKUgqY#2cg!G-4I3ASVktg3(JV z;Q1Gx7bT#f!-gd*>!jpUw_ZIpBZ5_8?%i`s{<;xoiAf#_ffgql!_yO`Z zj6Q)q{#93AsrvNkV>454e;fNg3Igz+3N%@4hhnh5mMS3mZ$CasRXG&Wpm9*Q%>eK6 zy!*vFu2-l@Z?5(@USan4=H4F9es8@uAqm@1FvSF})bguWufDpt3?C)3)FSV1D}gS%3HFBBUgzp|(xgebN*_;RT48}W34%0 z{&|@JPj)?s0OF-4h@3c4?I2$pfBbHr`7Pe}@VY<2d?Ty@5a z{1gQN*awz%Pp+d|D$kH5kta>$!Xv#QhUK1Z0*rU26G%$YZU7VRu|PIGHw~AD3cJ$9>&uD0|RG94!&k$i_t-E)suoL__VEy{_)gzBQ0z<2p%ZkR;ipO5bLO{{#OHMl5Nu`-|05$vKg(?t_KJ?Iv zlFWQfUddCSxVB%Q&|MgyI;0|KpJ5pIGL&thuvX2QHGR>lJ`jMZGF@w}5hjSe67-x( zjkEgY>ji=tKmJa#=gE88LrWG3>&`pcGbdf&H{MXc{`xD7hTg8)wQHxcv$JDJfQ9;q z6K^Yoi>Si636_Rw5thes^e@MAdD@AmiD|aY3;Xx&+gGd@KMP+3Sy@>LBLOjSmcU?* z);I2V{Ucd!|i>hDP2C9+s%Q?^3_eDe)k4|ie0O-Z&W60_}4o}#zAPN&OJn4Sz z&9E3cbSREsmWC$?hJ=tvFeu6mE1y>)rBSMt_E-FvS6q38nl*EleDruKP`p@i*@wrI zCR$vo!S=#Ff8@xKVnj7G`Mp!o_+=`A`(6s6*=15vSzd1PJ{nshFPJ-b9vqeU=hILK zkLJY)Hk2z!fu_*1V5!N#H(!74%d=1n1Yk{EVw`9|lGn)8M~)XG6C~uIq7T6L1`Udw z%>nj?e1og6xf+;#b!;SH>tf1h_!TmV)umZkS?Y%$eo#+7@r0~2T!k!hmc!6K6q%D% ziQ|rI>bdWYRhHlPw6V_>G#!jazjgA8M=Goygp>@}du!ILnI_oRm6nOUsBlZPNU6$| zDhKnS4$Tx}M<5s)eiH)$1gaaj(B66^=nt>C^g$mBS%jzOfH6O%dgB<5jIu<49AP4n zf(A2Z&Qv$uc#{Y!`DvaRWYwxw&vjSOz{eLbpmG*XfMe!!@>>wSpNTZ=1K*@d#+9r8 z|F?H7@LEl2e=)9u8TXk;N{B=;Dr36hBR?Od&<%+S>4rkOFv3hs zd{dN5GDc0O489q~jL6V!egFSj``LT#{qFaD_jxbpyys}0-+9-*ti9H=o^^k&99T3K zTk3&^NK)Iu(vK?2<|ffFZpR=k;mR72mNb9Q>NeUukCYg7Xb)Afq7m?6hQTGL`n@L;1I*@s39q(XX&i3-?RDf80t~xDpDN z)6qvAt@hY+4`=6O^L`nN9SO`830&M{2PTt={oXcARN?3^fB7&%n+t!%Zxc-bTefU9 zE{`fw6XuU z@4ovPvBUVRf~VXGoN>k(MnHty9J*AJiNKGMH>I~mj~=bghBCuSF?bMUllbb)GyhAq zXwlN}&J%xBO%w8nHDJI%b1akANmrG6_3Ej7ZS-x!YJ1;(_xW4TbY-!tux&qu(9=a1 zU1X%v(x&~9yx=bQq+eM%38^x1^ge9;i^C6jo+P%8Pg7f6p2QuAvJn~+$N|{%9Qm#6 z3@ux>O0>13(!{;@js5OeRTjFN9?i3?(omH(FlpW4f*ZQ!RpE zw~aCeAGz)PF@!hYe8cE0i1JdpI&<)I63ACfcK-bNs;sO`b?@HY4073Fuv~U%3Ec8u z4e7~Z8*!2HNO^_3a25}6_c5>UzO-IP| zjW^zi9j4YGGI;;IHjgyee7a%7<23k|dhzS8YXD-t;dfR2KWNY(1LWwt?y7QFDiaqV zdTJ=Zn7Xr_*i>F~*+#=J#ELci^%Vp!=*;@rXP@=+b}!bgTdQxr`DRU+Dia5#Ue=js zou#oV8&q*zF+g5M6PLNbe$-J%YaFjkJ`f`hja5DzIdWu5zAaj`NPz+RE`GY09ROz& zz7GO*mZ`Kr`TU+ed#aNl<@MIvm8Ys3KoY*9ZFLH#kRbHp3onFU*6=$DN)3l7?(@(9 zqs=>ic1QDV-@eT#Gf^=F{h}b4po}@)fm8Rqb*om2Ca&l(lf34aU;ZDkDwoufeXnRl@vdf9fT*5y4>xW)l&xF0X|RKI`wkt<>@#WNB!3102vu?oM~`~RlBEWoWp6LjI0__&0|Q3yw%hKB z(^pj;laxDs`r`%%-U6ecWTda`b7hUFTax;hY8nhU{np#T4vmcjvtAmWi*jxo(ACVU z&%B~L0PZk4bZDQF_t6cG)}R~l6aQOx2atgX2-JGmrBqp+I&~EJihqZYPS>to6=lM! zDsVO%2Me^#npytz)M-`v*Nz#%}^ZVZirp+lC*R5+eHF46!l83vd z1K6lhBXvCfZQ!_6iW;8r3OzPewqMG+kOsgVg}avXd{0*Gsu&-dGz*`}9%9xBK!7@; z-Vy3+uy_6l8RNaN6`__~lD#iIeccN~!& zrzDXC*(U(6B+h6IlVB2@d|q0A>D8-OB)j6?{sj8Yca6SF_3z)`KUX~fz327oH&m!# z^2amHiWSPN1QOqK&lq(MI^3c~i(;XnI4_-M>E0S^50&OOf*#uc0TE!V@O+GeoB z^9khW*kGk+-MnZ#C)NtIAwWc@i{Q)b6M&bNW}&GO#)3%5V4!o5%Ci4P#g>UEmx#2p zs#5R0_Z}qU9`zth7VQbGDVmVZ*Go*Y(kdqvS+u?b$w-m3*2C3j=+K)YZ+6@k3j?Q| za*ApKWhe$JD-9elKnXi8pE3`DU4hhK4+FGm85hVp%q9%R>A_(GhKYayacProbo}uq zEeRPbR(wFZf`&OOeZmR$IrAyTKa3q{m^(7%32}1smd*N{bIz%7d5sz!r@_MO6B zd`a*VA|M}t3#Qw&X%kxx>;>wtG7te#rX95DxBxQXSkP5hUF8?jx^-&}VIMxmB(Pey zY}wL(Cwb-@WBa5p?a@QSq?Rei@KOm7!u|K(pHq2PUU{XSI(2GJ-{xDN%KW}tx9&dA zSa{GlI@S1kS0;|*$~vreZ4GnmrjJ6iwO|X)e*5jKLGLp8vOVRW5B~bsk2P!~#I`Mb zIqDxaZ}z|Ah06s%9gi5Rz+;St?s(49f>0sV@I9zueOR&##h^jx`t|FFU`Wz1OjD~?Ee!*v;FosD z>HDPRRAsthkjR5BxWSpM5+?Sr2nQ%Nw(eDlca{y&Yh7NxM&EYZZ3SETrB0o@boScR zE`*!_7(ipt(1S_arh^7u>wRZ;4ZO)|0`SVyx7>0|N?|>)O?L+@&m^xdSYUPp#~gdC z-VH(#Qjus(fr%q=#=rcEEA$I5zNj~S_niUQ{gJXi##ZNej}8&dC&~R4C}Y#6?{vds z8>WRPNj{~T2Bx`vv;~7dpy}}C`t@IE$fwuH`d4MUkftJ~u>^R~KkdV>LJm zIp-72w;1=J>zbn$k6Z4MD^ppN#h1WN!bbF>P|P%oUthdKZr?llX2NMj-tB>f?3iO3 z6s2E5-bO=-XXCu`Nm^cIrUPr#;Evd0JMkxs zOTnr49!W?D&9rh~PC z=%bG`%p~cnA!~5>;fE*6H)-RKy>BeR)$hLdo`y7ILGw-uL%!);dH@Mdl-nS&)daKc zx0jqeVVBY8!a#-BCP5nMKoZ+8w0`4_H#AhMt0i9Y#cQd54}X!qT!FiaUvkMM8Qbfp z(2jlY{rA1Lmm{HFA@ZI8WEVhoQ~&IehJ(pd{XX#K?Z#^7Aqp0kbCv+ z<$o)A?1(d;&x?(po|j+lw4WWo@W2dp_T4hJRjXF5d>I{?I4ZaU$h-@XN#OhM|D!Lz zyk|yWijAan`IE>s|D~4<0=ZSD^kTZSrShDEcBh>oYy}Pgl%F(w9q1+}o~Zjkf^p9C z&&RS!SwZ+9^y|l?87aryDZt&i(}6Nf1SXZ%4?p~H&K7G*1&1{S4yfU4IH~p8&kla5 z{b~;GY;_zzeu8aL4AeX(fMk;Zyp?Duo6s+0R=~0WUdZ<(l1PA+;%Y(gYSpTxhE|_m zi2wgx@@M@wh<~!t-5@i0=otv8WV2GpY4PIMwQLc|nc?w@A-eiU?!=_DHv~ZD%KdapT5iZ5QsY|M|~<*yb$u1i(%tVe;fjd09LuWW!LR5XKh- zG=1H5*JVXqAVFoRWY5U*IVLZ5fudHSJ;9@6l`;JmSy9P}cu(XnGk zZxzTs2}pZFTT+i0F}&K+OmfA4DSgM?7=ujs<>lo@nl&4Y!-oyGoxpUOymtWEcLCCA zSn4vEK}x5YlnwAgy=TW??g%A8A6p-KXnt}8g@P?fK4+eJrcd3cO-ZjEO{3D}7ma;V ztXcA+w&bW$qcH1#VH@82oiJgdNyDSSBab{{o=RTXV##RHYm17a=q0f4h~sz7q>X*O z(F(|M0wBE&mbm;jPlq@5{k3b$ZS#4JoaYfB*#saRiY3G3u^uz#51~$)4&XvR=L-5f z&dLmwE9RhxC;$K$G)Y83RAAuNxX|SkJHM$@rWn{QUTquj!L(`9($=MZ{Ub3E+^#?S z>~rti?Y_!1JI2bDE1f)piACDZjSX?o+uiX9GX3$#&DoQaNwMp`BY&kQKWtTsW%Yj? zwjYy7g_7;gV7^0xx`SWxO2`F>ew?|(ggTD_LF+X*zfwMWzy0^GluiSWv6>o-J_2w- zJ$Rmps#~Wb`==aRK3f-ydtNVCuuvZb`3kBv%PxQfKUbiKT$S5y8>J~owRY_~4U0gg z{#4Qm^9dy61(&@t!~3(QYXh&)4l+Y~p;dSfk+K1KA zb-*g-gm)ac9^{O)22AHb@TJoQ7wC?iF2M1qy>8d;Y<=o)T9`CcAUo*bgFqZjFE?LE z`18rDS+i!)S=nRp_Hd-jKHc~ahmY+w_-0hw(Ah%pO}Vqyc!x7J5uUpo(t6(Zcz zgwfcsW8o~KFI;|4F}C~Gty|}$EZk+Yrp?rS_usEhIi#_BK-{Gmv7|A5ehUJ zg$dYLQGybedgYZ@6eKj^S!h6{7HnGvwr{wdr7dZA5*z_v7NF@0wPC}0ICDw0J!d}8 z@#hzCU~&Zd3KYHI-?5f)D-O|Dcp3T>ZTG4A@~aKTnatP!{#yO8`3HrOVQ?WZVFFaU z?YbM>*8N=3Lo{^b)h~Z>usY(1`Y>jDxPpwnI<)qo>LV4yi?tR3o zRH z4%rdd>B#>61BMc&KK7U)UU9W%x%vK6mf&u%x_oU!b7r=C*O$No%qFdvZ~4Xke%iF@ z?u725)0hBP7VS^K;5l6aU>2xXb%96UcEfCx2+WT$=^T8sCZHlJP?Lf zV9AbzW+}BsKgBMM%3goC`)++UR_)rL*A!AuVQ(mZH3ANLT-rlF@x&7v?&qaiu&lF9 zm%~}dAD?*^qP2ZBE$CFFE`?6pt$TO7@_Waxbb2?@ayxcBU&93-2B)`M!~l%0{&^k& zq8V0jBJe6Cm?wMdvFXz^Rdi24Cr&a@xqaIgVymR^UkQo9*>mOu10NZi3VP+hO<0vT zX0(xVxu;ZQU=hi*uc@c#gY(G4BLMTp%3;t;+6TPPLKOI-Sb-591xTl1kcrxM7Wdw~ zHb11PMD0Mt)rGzpe*NoT>$=#cN8fV-`tNhkHTnl1ulm@O%go_eL|@$fVt;~)ewXbY zR-(=EhqNEcOdM9{FhlUAcyl^m5eH!LIsg3gb=i!vQ2!x3^t|)zbI}-W>?1IBsClwq9ig-nlv%Q3g+3HE~|t9DUkhb^=jR_SFf~#)Wz@5 zQLm;r7kBN}RgXYk zsIlSjJ%r8j4I4LpV|E-=lA+%|?i!?QSWniEf>jE0@u6Z!jKkTw=<*QVF7B?kdX}6% z_+SOvsh$s;z!(?9KZk+WZ$DfFV^IAiC)X$35s9 zB$a#_zYMX~=2*!E&bXM{_C9_3#2S~8{t_QcKZ}p;(&$2?^#|Co42Fp`*Pv#b)@?Lp z?W5sOe|jm=F(|H1*`6JXoe8{Tn<77k`>^A1pY0pI=kZw-!rZYDj~_ceSx6;M0PeuI zoh1q&fto*Weu&TcE1dI}F15b@_&!shkOa3uxe{>d*FQqT*MYA=mjwA$k%sEO(Lh`> z&_;vQpSvibsnB$~1TU#K5qkqgULvO9l@p02B@x|)@rqSu^+ zB?xSsRIb1NIv=KV`P2WTSiW)di1wGDv&Rp1`##;fU?4~t6ccyl>vxBu4LBvi?!}^5 zuYn2tQ$gcM2Y)WlIr+Q}ZA=DAZ#IVU&m{SD?9|Z^f%%nDfJoCSHT1zfDl+- zhhqZ8ZDe!{PUKLIfZI`OtC5FIcgx$hZZmvR8VKdNiQC>JLK5i^%QGcB=_W#ax;!f5 zIVs~*r*?x3GMYN$Hdf%+6`h!apO%0bEY78~xH)LJ8DDCSO|pHLJlvRPc^Y|fj~ASD z+O<2&s1lRtD8V)C!lOsFRi8VG8=&4y^Km95?96VRa=n(F0gsLY2ZoKs(EcP?r&28w z{{#KBP?D}x{1ORGE_O*xid_I%2hngf?VlK5b|$OB2|y+SiQSI5JuRDw9rJJqw@|hB z^wZBU4M0&zIQaBCyxUnTa!vo~-~aHvVVzavyQ)gdqk!0*cY7w!;)IDru5&?c=cFmY z9UStE9iMgpdi1z7<97eRgAUZgK;R1rm>?4hSdsN)1}@Il4AL3R4St?@;&FWtDD#)iE)wI7-&OGhAPr>^FP)Qsar>7|&p91pa(Paw`EpQ@ zDML`5qy@!^3vniaRB!Hbt36A_EC!+>YgvPgQ(F(Q5jdXW*DKD}lKSe$1c*-9ZglWM z0+|HqWW49MF={dq`&?4QCU^PA<^i_H=q_dbmn4A0>m4`!bJ;H6v#MX!5^hf zfZtWCRvE5JCVa-dqYTR%Z@v+tJH(}vXH;Z+YF4mIBCc}97lE(h3BbTEbqK`-?*A~$ zHq2mfRFINyOa&nc+~Vb%UuA#K6(laW=Wo8}R-JVf0%1Bhxz&ZYXT1Wp03?{fVEURJ z6;!VtJN7<*;F2EW4nlrMZca9O3~d&C`Q-*vjG4Slhqe=$Og72m=y%cn6^79pJE49T z_l&?T1#KiM)I?z&ax*0G7`5-d@ou@IxaKO}zC(MkPH#@^p@{-XD?K`nDboWsIew)6 zHfA1GJOQ`>MH|bal|-q0&45RdFZXN&3Vui+8>LW09Vw$SVzMlS|4#m~8Awto#N4`* z-%r!f+2A2hTqw~d3l4f-n89zJ7%^ko2N0rSc<|c^;_>OzeW}EBWnSKsXV`CTF`#*CTLZYE}jhGI>kM#;6@VtqGj)+|#8 z!5MFsp~F&BkR2#nxrZKF+lc0he@B~Hc!2O3JK>Et-r)1cWtSnHk!hAJS)wUoo2ktH z{rl-H;N&pa2}=ffWet9o7p`Bnd%|iLr0_&a(Pq&+Wg5yZWU6~b-nGXLNep^2Zvrj2 z&NpUuB|ia3ho%htr9FBSM1`|``}Ue_LlZoN@_H<0*@`qyNu>2mul4TT+f-*8b}=N# zQQS3?0FMiMmo8oOYVfrs*p3w$9Nx=Oqi%z(z{%#$41Tlqw`uus15B<{Kf}#GdOc+D z5JzYw7=?BK7OsOQ8z7N-2H?a_5Uk^3a8;;l;MR)@Z6as|Ey=tWtdqdynhB0btZ&Bx zO#-Y-2~7Z*XJi7)K*_8Nq1Zx2Ah!XsU6XxyDuhd@M-WI%p-j8MHLM-70kh>(s_!Y* zOE0uk?%~lU37lj%(Na)qCrcd^#67q5gTP%N2f!Vye8<26N7_NAvWE;AY|@$Z-cK;$ zRQBMP@>1(->lgo=V9x>fWUsx#X60#nK^B&|b6-eA(U0Pal7J6E#=@6_Kv~HqGO0`Q znDr>32_PQXZbJ}Y2_J*%Iv$<3E0kZ3hs4w&ppG7ZS$ThKcMpWc8&XS;f;#6}?b_*^ z;HG&2sQcm@UGi{avdjvpb{cKiQoAQB>qFTE0TJO&7H$jTi4^Y+?avDl?ASI8(9JjT zO!$1vBtX;;9EEM$wwkmo52cub!euA#z=i!TTxYl6MXyZ-@H6GnM^f0Ezd7|(U-bEs zi!|B1sy@U67nfK`2zqVXwlx#s6ll*$N2B?yF*qW)lcL3o;6&mu?unthMvpcUaJ)8ymls~R z0|F^SvBRRgyp$Dv2I%PqkPARgi2#z=}3+hNmo0uS>MB!n6>+L?=e57Sn$<@0z#zPhU^ z83)T%SK3as@tcjh;c*QOv5FdfuH;(K(W3YXHhX&pIA?l_r?!J!0M%a^_`R%ryr%%tPe5Ef@uYaI z!Pc!?2Q&kdK@u1W(Z>1a5#S-oM8K-O`|dj_MmrvO-~mH$j%rnsWAK*`Ef*?#o8()U z^xqTi5}p8Dz@EZP-fm;u}bZl3O!Gjl^IwcgAK)Wl65cyY6mD%u3Pts z@d#`_!cWl2-0L{}^1c0v=l=80@1T47?x?-N@4a7BzQ*h>2u~d0uL(qDMIG4YX&TOf z#Vx;7uPr`opUZqrtWCv{%Wa=yA;n7NvODh#>@%=`aqG_zv`f|0pD ze+S#!Y($!>oDev$?uR8E2C|pZ87ZLwlMrju8>IZscHV6GlXU!oM88d%=XTOXNZeyE zot3e<(|Yvi^!^pK{ zPAkvT#Wp?j;Ca1Em(B(+v@FEn=F5BfsB7A^X)Nw26}NEV0w&{_w-bgBSGw}6!}BOk zqxW_a`26$FU@fS=kDJb&JDa{H#{dct(QKQ=Pu13&Ft`)I&ghUeG||Ko$PLKHUj$d6$x`n#SpB5j>{}{#vyTQNR1$?~UICRusn|MZCb0vZ*%~9);soN#dXe zP5?m%7mV}C(j`mOZ*RF(9#wkGE`!MzKk-FlwNUW+hfcIJ9C=))o_w+_CE=0v>#OP0 zr+dyHIG6BGGIV3PPRYW5z`?&^{dxs+UTXGpv(58?0|qJxRw*prema@4UR|)jIH_=6 zm`ge!>C&Yupx|W0)HP-BOHGLj7a#6(UfXwYz^XcR>S$iVdX)kT6pl%bF!x~lDfm4w z*QNGT!zKWB!629pi63aqsos71sDEL#E5!i6eF~v?+rvuA?$5J2Yi5bMV8$kAir$nm5nxh#DMyv;zOyc4sE{0@Gq&ub^unkNg>AOfjNF1=L2CcYU&I13s$KxFfC%y{4~% zu7!83;N#%CFV=sNvVzsCS5J)@a}RbbyO;?nNbP4?11|)hQJmY_A6BQ1IR(({+ZqlT2$>M{xseo&+swHB?Q&FSD8In4*v6|W~flFo9ztI0}+O{#y9cXcO z8Wd_M5=*;!QQdNiBQs2aans#*PxS&rhaS&8YkdNc~d_a9LHU7V%j!yU*J zL8ae7$zNjMrC7>@V)}53#binVimgDx!C9abI(Oy5Ah>4TdCi8g;rs7@Fcr? z^Abs-V4wtUu=iG^9&h??lmDC-Yy+6OynFwgxK@h)uSKjjxDU8bKABZnLIVKfa~ zcqy1zH;?1me(Tn4hKAvO*Rn+mCH)1!CMezg`p0oC{iGCY>$e?%W6$C2ZhXzNBVdBy zZHWi3!y|ILK6$MqzK{h|?6A+z_QBw8tVo?FWsbai4tzqiapTu&;lf1^&1q7cAEF@4=Ku!saKH7vn)B*6J=PUpPN$J%EdzP-gZKz$cBQ0i`cd$ z=Rv5|;kL)XMz-`%PYwWM2_#t7ty|aBTOg2kKfX8~_ujB7IdpK6=VAnl#s)S*zoQLH xu9 Date: Tue, 19 May 2026 19:23:53 +0200 Subject: [PATCH 029/122] chore(gitea-runner): bumped patch version fix: reverted quote autoformat --- templates/compose/gitea-runner.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml index 81cce4492..42bb21984 100644 --- a/templates/compose/gitea-runner.yaml +++ b/templates/compose/gitea-runner.yaml @@ -6,7 +6,7 @@ services: runner: - image: 'docker.io/gitea/runner:1.0.0' + image: 'docker.io/gitea/runner:1.0.4' environment: - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}' - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}' From 597a2d806f8c21934d48fb7766ba2e59cda16054 Mon Sep 17 00:00:00 2001 From: toanalien Date: Wed, 20 May 2026 01:05:14 +0700 Subject: [PATCH 030/122] fix(templates): correct image tags for hermes-agent and hermes-webui Pin hermes-agent to sha-273ff5c (no semver tags on Docker Hub). Fix hermes-webui tag from v0.51.92 to 0.51.92 (GHCR has no v prefix). --- templates/compose/hermes-agent.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent.yaml index 848a476e2..9f7bceda4 100644 --- a/templates/compose/hermes-agent.yaml +++ b/templates/compose/hermes-agent.yaml @@ -7,7 +7,7 @@ services: hermes-agent: - image: nousresearch/hermes-agent:v0.14.0 + image: nousresearch/hermes-agent:sha-273ff5c4a47af4499bbe5e3b1139efd313995554 command: gateway run environment: - HERMES_HOME=/home/hermes/.hermes @@ -28,7 +28,7 @@ services: retries: 5 hermes-webui: - image: ghcr.io/nesquena/hermes-webui:v0.51.92 + image: ghcr.io/nesquena/hermes-webui:0.51.92 depends_on: - hermes-agent environment: From 9264f391cb771c0e349a34edec0820b511a81a42 Mon Sep 17 00:00:00 2001 From: toanalien Date: Wed, 20 May 2026 12:04:26 +0700 Subject: [PATCH 031/122] fix(templates): address review feedback for hermes-agent template - Remove top-level volumes block (Coolify auto-generates it) - Remove redundant restart: unless-stopped (Coolify default) - Rename hermes-agent.yaml to hermes-agent-with-webui.yaml --- .../{hermes-agent.yaml => hermes-agent-with-webui.yaml} | 7 ------- 1 file changed, 7 deletions(-) rename templates/compose/{hermes-agent.yaml => hermes-agent-with-webui.yaml} (93%) diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent-with-webui.yaml similarity index 93% rename from templates/compose/hermes-agent.yaml rename to templates/compose/hermes-agent-with-webui.yaml index 9f7bceda4..2d30396d8 100644 --- a/templates/compose/hermes-agent.yaml +++ b/templates/compose/hermes-agent-with-webui.yaml @@ -20,7 +20,6 @@ services: volumes: - hermes-home:/home/hermes/.hermes - hermes-agent-src:/opt/hermes - restart: unless-stopped healthcheck: test: ["CMD-SHELL", "test -d /home/hermes/.hermes || exit 1"] interval: 10s @@ -43,14 +42,8 @@ services: - hermes-home:/home/hermeswebui/.hermes - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro - hermes-workspace:/workspace - restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:8787/health"] interval: 30s timeout: 5s retries: 3 - -volumes: - hermes-home: - hermes-agent-src: - hermes-workspace: From 077c68e4c4b15d1012169241e4a6332177df10a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 20 May 2026 16:44:18 +0200 Subject: [PATCH 032/122] docs(readme): remove Context.dev sponsor --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 43ca5c4c3..0b76e864a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ ### Big Sponsors * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform * [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers -* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain * [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor From b9f773c1d9d37eabee8778b04bbd5f2984ef7e3b Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 20 May 2026 19:04:43 +0000 Subject: [PATCH 033/122] fix(livewire): stop broadcast handlers from wiping in-progress form input --- .../Project/Application/Configuration.php | 17 +- .../Project/Database/Clickhouse/General.php | 13 ++ .../Project/Database/Configuration.php | 16 +- .../Project/Database/Dragonfly/General.php | 15 +- app/Livewire/Project/Database/Import.php | 54 +++--- .../Project/Database/Keydb/General.php | 15 +- .../Project/Database/Mariadb/General.php | 15 +- .../Project/Database/Mongodb/General.php | 15 +- .../Project/Database/Mysql/General.php | 15 +- .../Project/Database/Postgresql/General.php | 15 +- .../Project/Database/Redis/General.php | 99 +---------- .../Project/Database/Redis/StatusInfo.php | 116 +++++++++++++ .../Project/Service/Configuration.php | 29 +--- app/Livewire/Server/Sentinel.php | 4 +- app/Livewire/Server/Show.php | 15 +- .../project/database/redis/general.blade.php | 50 +----- .../database/redis/status-info.blade.php | 51 ++++++ .../Feature/DatabaseSslStatusRefreshTest.php | 163 ++++++++++++++++-- 18 files changed, 470 insertions(+), 247 deletions(-) create mode 100644 app/Livewire/Project/Database/Redis/StatusInfo.php create mode 100644 resources/views/livewire/project/database/redis/status-info.blade.php diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index cc1bf15b9..887fff35a 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -17,17 +17,10 @@ class Configuration extends Component public $servers; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh', - 'buildPackUpdated' => '$refresh', - 'refresh' => '$refresh', - ]; - } + protected $listeners = [ + 'buildPackUpdated' => '$refresh', + 'refresh' => '$refresh', + ]; public function mount() { @@ -51,8 +44,6 @@ public function mount() $this->environment = $environment; $this->application = $application; - - if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 2583c10ea..edcb31f5e 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -48,13 +48,26 @@ class General extends Component public function getListeners() { + $userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 7c64a6eef..9f952ff2b 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -2,8 +2,9 @@ namespace App\Livewire\Project\Database; -use Auth; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\ItemNotFoundException; use Livewire\Component; class Configuration extends Component @@ -18,15 +19,6 @@ class Configuration extends Component public $environment; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount() { try { @@ -55,10 +47,10 @@ public function mount() $this->dispatch('configurationChanged'); } } catch (\Throwable $e) { - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { return redirect()->route('dashboard'); } - if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + if ($e instanceof ItemNotFoundException) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 9e1ea0d10..ae8ec9476 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -57,11 +57,22 @@ public function getListeners() return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 1cdc681cd..0c19709a5 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -5,9 +5,17 @@ use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -192,15 +200,9 @@ public function server() return Server::ownedByCurrentTeam()->find($this->serverId); } - public function getListeners() - { - $userId = Auth::id(); - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'slideOverClosed' => 'resetActivityId', - ]; - } + protected $listeners = [ + 'slideOverClosed' => 'resetActivityId', + ]; public function resetActivityId() { @@ -219,7 +221,7 @@ public function updatedDumpAll($value) $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -231,7 +233,7 @@ public function updatedDumpAll($value) } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': if ($value === true) { $this->mariadbRestoreCommand = <<<'EOD' @@ -247,7 +249,7 @@ public function updatedDumpAll($value) $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': if ($value === true) { $this->mysqlRestoreCommand = <<<'EOD' @@ -263,7 +265,7 @@ public function updatedDumpAll($value) $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': if ($value === true) { $this->postgresqlRestoreCommand = <<<'EOD' @@ -321,7 +323,7 @@ public function getContainers() $this->resourceStatus = $resource->status ?? ''; // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $server = $resource->service?->server; if (! $server) { abort(404, 'Server not found for this service database.'); @@ -359,16 +361,16 @@ public function getContainers() } if ( - $resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class ) { $this->unsupported = true; } // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $dbType = $resource->databaseType(); if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { @@ -664,7 +666,7 @@ public function restoreFromS3(string $password = ''): bool|string $fullImageName = "{$helperImage}:{$latestVersion}"; // Get the database destination network - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->resource->getMorphClass() === ServiceDatabase::class) { $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; } else { $destinationNetwork = $this->resource->destination->network ?? 'coolify'; @@ -756,7 +758,7 @@ public function buildRestoreCommand(string $tmpPath): string $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -770,7 +772,7 @@ public function buildRestoreCommand(string $tmpPath): string } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': $restoreCommand = $this->mariadbRestoreCommand; if ($this->dumpAll) { @@ -779,7 +781,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': $restoreCommand = $this->mysqlRestoreCommand; if ($this->dumpAll) { @@ -788,7 +790,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { @@ -797,7 +799,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " {$tmpPath}"; } break; - case \App\Models\StandaloneMongodb::class: + case StandaloneMongodb::class: case 'mongodb': $restoreCommand = $this->mongodbRestoreCommand; if ($this->dumpAll === false) { diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 7c8808499..0511c9d04 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -59,11 +59,22 @@ public function getListeners() return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index ea6d902e7..edd02eb95 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -64,11 +64,22 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 3af4b0b2a..1b5a62d2f 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -64,11 +64,22 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 34726bd0a..6e1e55b3f 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -66,11 +66,22 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index b5fb85483..1b36ac28a 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -74,13 +74,24 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', 'save_init_script', 'delete_init_script', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index c3cc43972..aff7b7afa 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -48,25 +45,9 @@ class General extends Component public string $redisVersion; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'envsUpdated' => 'refresh', - ]; - } + protected $listeners = [ + 'envsUpdated' => 'refresh', + ]; protected function rules(): array { @@ -87,7 +68,6 @@ protected function rules(): array 'redisPassword' => ValidationPatterns::databasePasswordRules( enforcePattern: $this->redisPassword !== $this->database->redis_password, ), - 'enableSsl' => 'boolean', ]; } @@ -122,7 +102,6 @@ protected function messages(): array 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -136,12 +115,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -161,11 +134,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -177,9 +146,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; $this->redisVersion = $this->database->getRedisVersion(); $this->redisUsername = $this->database->redis_username; $this->redisPassword = $this->database->redis_password; @@ -227,6 +193,7 @@ public function submit() ); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,6 +226,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -267,63 +235,6 @@ public function instantSave() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php new file mode 100644 index 000000000..183ed936f --- /dev/null +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -0,0 +1,116 @@ +currentTeam()->id; + + return [ + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + 'databaseUpdated' => 'refresh', + ]; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.database.redis.status-info'); + } +} diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 2d69ceb12..ac2b39bb8 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -4,7 +4,6 @@ use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component @@ -27,16 +26,10 @@ class Configuration extends Component public array $parameters; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; - } + protected $listeners = [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + ]; public function render() { @@ -105,18 +98,4 @@ public function restartDatabase($id) return handleError($e, $this); } } - - public function serviceChecked() - { - try { - $this->service->applications->each(function ($application) { - $application->refresh(); - }); - $this->service->databases->each(function ($database) { - $database->refresh(); - }); - } catch (\Exception $e) { - return handleError($e, $this); - } - } } diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b..06aebd8f8 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -93,7 +93,9 @@ public function handleSentinelRestarted($event) { if ($event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 3e05d9306..d7339dcdb 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -277,7 +277,9 @@ public function handleSentinelRestarted($event) // Only refresh if the event is for this server if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } @@ -457,12 +459,15 @@ public function handleServerValidated($event = null) return; } - // Refresh server data + // Refresh server data and only the display-only state that validation produces. + // Never re-sync text-input properties via syncData() — would clobber any + // unsaved typing (see coolify#6062 / #6354 / #9695). $this->server->refresh(); - $this->syncData(); - - // Update validation state + $this->server->settings->refresh(); $this->isValidating = $this->server->is_validating ?? false; + $this->validationLogs = $this->server->validation_logs; + $this->isReachable = $this->server->settings->is_reachable; + $this->isUsable = $this->server->settings->is_usable; // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 73ee5f0e5..b72b05ff6 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -60,56 +60,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @endif - -
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/redis/status-info.blade.php b/resources/views/livewire/project/database/redis/status-info.blade.php new file mode 100644 index 000000000..9f329504c --- /dev/null +++ b/resources/views/livewire/project/database/redis/status-info.blade.php @@ -0,0 +1,51 @@ +
+ + @if ($dbUrlPublic) + + @endif +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if (str($database->status)->contains('exited')) + + @else + + @endif +
+
+
+
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index e62ef48ad..b663213a5 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -7,11 +7,16 @@ use App\Livewire\Project\Database\Mysql\General as MysqlGeneral; use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral; use App\Livewire\Project\Database\Redis\General as RedisGeneral; +use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo; +use App\Livewire\Server\Sentinel; +use App\Livewire\Server\Show; use App\Models\Environment; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -28,25 +33,75 @@ session(['currentTeam' => $this->team]); }); -dataset('ssl-aware-database-general-components', [ +dataset('database-general-forms-without-broadcasts', [ + // Redis splits status-derived display into a sibling component; the form itself + // takes no broadcast listeners. Other DBs use the narrower refreshStatus pattern below. + RedisGeneral::class, +]); + +dataset('database-general-forms-with-narrow-refresh', [ + // Form listens to status broadcasts but routes them to refreshStatus, which only + // writes display-only properties (URLs, cert expiry) — never input-bound text fields. + PostgresqlGeneral::class, MysqlGeneral::class, MariadbGeneral::class, MongodbGeneral::class, - RedisGeneral::class, - PostgresqlGeneral::class, KeydbGeneral::class, DragonflyGeneral::class, ]); -it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) { - $component = app($componentClass); - $listeners = $component->getListeners(); +dataset('database-status-info-components', [ + RedisStatusInfo::class, +]); - expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh') - ->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh'); -})->with('ssl-aware-database-general-components'); +it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // For DBs whose status-derived display moved into a sibling component, the form + // itself must not subscribe to status broadcasts at all. + $listeners = resolveLivewireListeners(app($componentClass)); -it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () { + expect($listeners) + ->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); +})->with('database-general-forms-without-broadcasts'); + +it('routes status broadcasts to refreshStatus, never to a handler that re-syncs inputs', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // The form may listen to broadcasts, but only to a narrow handler (refreshStatus) + // that touches display-only properties. Routing to `refresh` or `$refresh` would + // re-sync every input property from the DB and wipe in-progress typing. + $listeners = resolveLivewireListeners(app($componentClass)); + + $databaseStatusKey = "echo-private:user.{$this->user->id},DatabaseStatusChanged"; + $serviceCheckedKey = "echo-private:team.{$this->team->id},ServiceChecked"; + + expect($listeners[$databaseStatusKey] ?? null)->toBe('refreshStatus') + ->and($listeners[$serviceCheckedKey] ?? null)->toBe('refreshStatus'); +})->with('database-general-forms-with-narrow-refresh'); + +function resolveLivewireListeners(object $component): array +{ + // Livewire's HandlesEvents trait declares getListeners() as protected, + // so subclasses that override it as public are callable directly, but + // subclasses that rely on $listeners are not. Reflection handles both. + $method = new ReflectionMethod($component, 'getListeners'); + $method->setAccessible(true); + + return (array) $method->invoke($component); +} + +it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) { + // Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling + // Livewire component so it can re-render on broadcasts without touching the form's DOM. + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners) + ->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); +})->with('database-status-info-components'); + +it('reloads the mysql database model when refresh is called directly so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -75,3 +130,91 @@ $component->call('refresh') ->assertSee('Database should be stopped to change this settings.'); }); + +it('does not clobber server form text inputs when sentinel restarts', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid]) + ->set('sentinelToken', 'user-was-typing-this-token'); + + $component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]); + + expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token'); +}); + +it('does not clobber server form text inputs when server validation completes', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('name', 'user-was-typing-here') + ->set('ip', '203.0.113.42'); + + $component->call('handleServerValidated', ['serverUuid' => $server->uuid]); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('ip'))->toBe('203.0.113.42'); +}); + +it('preserves typed input on the postgres form when refreshStatus runs', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandalonePostgresql::create([ + 'name' => 'persisted-name', + 'image' => 'postgres:16', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'exited:unhealthy', + 'enable_ssl' => false, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(PostgresqlGeneral::class, ['database' => $database]) + ->set('name', 'user-was-typing-here') + ->set('portsMappings', '5433:5432'); + + $component->call('refreshStatus'); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('portsMappings'))->toBe('5433:5432'); +}); + +it('shows the redis ssl gate hint after the sibling is refreshed', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandaloneRedis::create([ + 'name' => 'test-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'redis_username' => 'default', + 'status' => 'exited:unhealthy', + 'enable_ssl' => true, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(RedisStatusInfo::class, ['database' => $database]) + ->assertDontSee('Database should be stopped to change this settings.'); + + $database->fill(['status' => 'running:healthy'])->save(); + + $component->call('refresh') + ->assertSee('Database should be stopped to change this settings.'); +}); From e7e65831a7c0d6b2ac39e98f0ded48afb39317db Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 21 May 2026 08:31:08 +0000 Subject: [PATCH 034/122] fix(livewire): preserve wire:dirty across DB status broadcasts The earlier refreshStatus fix kept user-typed values intact but Livewire still absorbed deferred wire:model values into the snapshot on every broadcast- triggered roundtrip, clearing the unsaved-changes indicator and making the form look auto-saved. Move all status-derived display (DB URLs, SSL toggle/mode, cert expiry) out of each DB General form into a sibling StatusInfo Livewire component, so the form never roundtrips on broadcasts. Shared scaffolding lives in App\Traits\HasDatabaseStatusInfo plus an x-database- status-info Blade component, leaving each per-DB StatusInfo class as a ~20-50 line declaration of label, SSL mode options, and SSL save hooks. Parents dispatch databaseUpdated from save methods so the sibling refreshes after writes. Tests cover the architecture (no DB form subscribes to status broadcasts) and the sibling's refresh-on-status-change behavior. --- .../Project/Database/Clickhouse/General.php | 27 +-- .../Database/Clickhouse/StatusInfo.php | 31 ++++ .../Project/Database/Dragonfly/General.php | 104 +---------- .../Project/Database/Dragonfly/StatusInfo.php | 26 +++ .../Project/Database/Keydb/General.php | 106 +---------- .../Project/Database/Keydb/StatusInfo.php | 26 +++ .../Project/Database/Mariadb/General.php | 107 +----------- .../Project/Database/Mariadb/StatusInfo.php | 21 +++ .../Project/Database/Mongodb/General.php | 119 +------------ .../Project/Database/Mongodb/StatusInfo.php | 51 ++++++ .../Project/Database/Mysql/General.php | 119 +------------ .../Project/Database/Mysql/StatusInfo.php | 51 ++++++ .../Project/Database/Postgresql/General.php | 124 +------------ .../Database/Postgresql/StatusInfo.php | 52 ++++++ .../Project/Database/Redis/StatusInfo.php | 103 +---------- app/Traits/HasDatabaseStatusInfo.php | 164 ++++++++++++++++++ .../components/database-status-info.blade.php | 94 ++++++++++ .../database/clickhouse/general.blade.php | 13 +- .../database/dragonfly/general.blade.php | 54 +----- .../project/database/keydb/general.blade.php | 53 +----- .../database/mariadb/general.blade.php | 52 +----- .../database/mongodb/general.blade.php | 77 +------- .../project/database/mysql/general.blade.php | 74 +------- .../database/postgresql/general.blade.php | 126 +++----------- .../database/redis/status-info.blade.php | 51 ------ .../project/database/status-info.blade.php | 6 + .../Feature/DatabaseSslStatusRefreshTest.php | 80 +++------ 27 files changed, 603 insertions(+), 1308 deletions(-) create mode 100644 app/Livewire/Project/Database/Clickhouse/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Dragonfly/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Keydb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mariadb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mongodb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mysql/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Postgresql/StatusInfo.php create mode 100644 app/Traits/HasDatabaseStatusInfo.php create mode 100644 resources/views/components/database-status-info.blade.php delete mode 100644 resources/views/livewire/project/database/redis/status-info.blade.php create mode 100644 resources/views/livewire/project/database/status-info.blade.php diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index edcb31f5e..b5c0ffff4 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -40,34 +40,17 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; public function getListeners() { - $userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - } - public function mount() { try { @@ -101,8 +84,6 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', ]; } @@ -142,9 +123,6 @@ public function syncData(bool $toModel = false) $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -157,8 +135,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -207,6 +183,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -218,6 +195,7 @@ public function instantSave() public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -233,6 +211,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php new file mode 100644 index 000000000..51a3192fa --- /dev/null +++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php @@ -0,0 +1,31 @@ +currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - public function mount() { try { @@ -84,12 +60,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -109,10 +79,7 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'nullable|boolean', ]; } @@ -148,11 +115,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -164,9 +127,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -215,6 +175,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -226,6 +187,7 @@ public function instantSave() public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -241,6 +203,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -252,67 +215,6 @@ public function submit() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - - $caCert = $server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php new file mode 100644 index 000000000..baeb3d09f --- /dev/null +++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - public function mount() { try { @@ -86,12 +62,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -99,7 +69,7 @@ public function mount() protected function rules(): array { - $baseRules = [ + return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', @@ -112,13 +82,8 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'boolean', ]; - - return $baseRules; } protected function messages(): array @@ -154,11 +119,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -171,9 +132,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -222,6 +180,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -233,6 +192,7 @@ public function instantSave() public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -248,6 +208,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,65 +220,6 @@ public function submit() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php new file mode 100644 index 000000000..1e87461cd --- /dev/null +++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -105,7 +72,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', ]; } @@ -144,7 +110,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -158,12 +123,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -187,11 +146,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -207,9 +162,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -245,6 +197,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -281,6 +234,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -289,63 +243,6 @@ public function instantSave() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php new file mode 100644 index 000000000..c6fda37b6 --- /dev/null +++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php @@ -0,0 +1,21 @@ +currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -102,8 +67,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full', ]; } @@ -123,7 +86,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); } @@ -141,8 +103,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -156,12 +116,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -184,12 +138,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -204,10 +153,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -246,6 +191,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -282,6 +228,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -290,68 +237,6 @@ public function instantSave() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php new file mode 100644 index 000000000..a92a682c9 --- /dev/null +++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MongoDB connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 6e1e55b3f..6b88d735d 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneMysql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -50,38 +47,6 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -107,8 +72,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', ]; } @@ -129,7 +92,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); } @@ -148,8 +110,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -163,12 +123,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -192,12 +146,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -213,10 +162,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -252,6 +197,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -288,6 +234,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -296,68 +243,6 @@ public function instantSave() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php new file mode 100644 index 000000000..5fbbc1583 --- /dev/null +++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'], + 'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'], + 'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'], + 'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MySQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 1b36ac28a..4e89e8b62 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -54,43 +51,14 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - public string $new_filename; public string $new_content; - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - 'save_init_script', - 'delete_init_script', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } + protected $listeners = [ + 'save_init_script', + 'delete_init_script', + ]; protected function rules(): array { @@ -117,8 +85,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', ]; } @@ -138,7 +104,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); } @@ -159,8 +124,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -174,12 +137,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -205,12 +162,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -228,10 +180,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -254,68 +202,6 @@ public function instantSaveAdvanced() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function instantSave() { try { @@ -341,6 +227,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -504,6 +391,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php new file mode 100644 index 000000000..cc27b61bb --- /dev/null +++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php @@ -0,0 +1,52 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for PostgreSQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php index 183ed936f..2e784e2c0 100644 --- a/app/Livewire/Project/Database/Redis/StatusInfo.php +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -2,115 +2,20 @@ namespace App\Livewire\Project\Database\Redis; -use App\Helpers\SslHelper; use App\Models\StandaloneRedis; -use Carbon\Carbon; -use Exception; +use App\Traits\HasDatabaseStatusInfo; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class StatusInfo extends Component { use AuthorizesRequests; + use HasDatabaseStatusInfo; public StandaloneRedis $database; - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public function getListeners() + protected function databaseLabel(): string { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'databaseUpdated' => 'refresh', - ]; - } - - public function mount(): void - { - $this->refresh(); - } - - public function refresh(): void - { - $this->database->refresh(); - $this->enableSsl = (bool) $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - - public function instantSaveSSL(): void - { - try { - $this->authorize('update', $this->database); - $this->database->enable_ssl = $this->enableSsl; - $this->database->save(); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - - public function regenerateSslCertificate(): void - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->refresh(); - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - - public function render() - { - return view('livewire.project.database.redis.status-info'); + return 'Redis'; } } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php new file mode 100644 index 000000000..98c939b7e --- /dev/null +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -0,0 +1,164 @@ +currentTeam()->id; + + return [ + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + 'databaseUpdated' => 'refresh', + ]; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + if ($this->supportsSsl()) { + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + $this->afterRefresh(); + } + } + + /** + * Hook for subclasses with extra status-derived properties (e.g. sslMode). + */ + protected function afterRefresh(): void {} + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->applyExtraSslAttributes(); + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + /** + * Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode). + */ + protected function applyExtraSslAttributes(): void {} + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.database.status-info', [ + 'label' => $this->databaseLabel(), + 'supportsSsl' => $this->supportsSsl(), + 'sslModeOptions' => $this->sslModeOptions(), + 'sslModeHelper' => $this->sslModeHelper(), + 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(), + 'isExited' => str($this->database->status)->contains('exited'), + ]); + } +} diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php new file mode 100644 index 000000000..a7c8dade1 --- /dev/null +++ b/resources/views/components/database-status-info.blade.php @@ -0,0 +1,94 @@ +@props([ + 'database', + 'label', + 'dbUrl' => null, + 'dbUrlPublic' => null, + 'supportsSsl' => true, + 'enableSsl' => false, + 'sslMode' => null, + 'sslModeOptions' => null, + 'sslModeHelper' => null, + 'certificateValidUntil' => null, + 'isExited' => false, + 'showPublicUrlPlaceholder' => false, +]) + +@php + $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.'; +@endphp + +
+ + @if ($dbUrlPublic) + + @elseif ($showPublicUrlPlaceholder) + + @endif + + @if ($supportsSsl) +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if ($isExited) + + @else + + @endif +
+ @if ($sslModeOptions && $enableSsl) +
+ @if ($isExited) + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @else + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @endif +
+ @endif +
+
+ @endif +
diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9283172ad..acba65442 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -41,19 +41,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @else - - @endif
+
diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index ce46e47dd..7f217f0cc 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -37,60 +37,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - - @if ($dbUrlPublic) - - @else - - @endif -
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index ee3f8fd0c..fa241dec2 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -38,59 +38,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @else - - @endif -
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index 1154124d1..b29b3e81e 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -61,59 +61,9 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
-
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index e9e5d621d..c1ec60219 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -50,85 +50,10 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
+
-
-
-

SSL Configuration

- @if ($enableSsl) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index bb3916ec8..e90885e7c 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -56,81 +56,9 @@
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
+
diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c956f5b3..ab6f6ed88 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -68,114 +68,38 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - - @if ($db_url_public) - - @endif
+
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - +

Proxy

+ + @if (data_get($database, 'is_public')) + + Proxy Logs + + + + Logs + @endif
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif +
+ +
+
+ + +
-
-
- @if ($database->isExited()) - - @else - - @endif -
- @if ($enableSsl) -
- @if ($database->isExited()) - - - - - - - - @else - - - - - - - - @endif -
- @endif - -
-
-

Proxy

- - @if (data_get($database, 'is_public')) - - Proxy Logs - - - - Logs - - @endif -
-
- -
-
- - -
-
- -
- -
-
+
diff --git a/resources/views/livewire/project/database/redis/status-info.blade.php b/resources/views/livewire/project/database/redis/status-info.blade.php deleted file mode 100644 index 9f329504c..000000000 --- a/resources/views/livewire/project/database/redis/status-info.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -
- - @if ($dbUrlPublic) - - @endif -
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
-
-
diff --git a/resources/views/livewire/project/database/status-info.blade.php b/resources/views/livewire/project/database/status-info.blade.php new file mode 100644 index 000000000..7107b3daf --- /dev/null +++ b/resources/views/livewire/project/database/status-info.blade.php @@ -0,0 +1,6 @@ +
+ +
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index b663213a5..7b0e4c0a3 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -1,11 +1,19 @@ not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); })->with('database-general-forms-without-broadcasts'); -it('routes status broadcasts to refreshStatus, never to a handler that re-syncs inputs', function (string $componentClass) { - // Regression guard for coolify#6062 / #6354 / #9695: - // The form may listen to broadcasts, but only to a narrow handler (refreshStatus) - // that touches display-only properties. Routing to `refresh` or `$refresh` would - // re-sync every input property from the DB and wipe in-progress typing. - $listeners = resolveLivewireListeners(app($componentClass)); - - $databaseStatusKey = "echo-private:user.{$this->user->id},DatabaseStatusChanged"; - $serviceCheckedKey = "echo-private:team.{$this->team->id},ServiceChecked"; - - expect($listeners[$databaseStatusKey] ?? null)->toBe('refreshStatus') - ->and($listeners[$serviceCheckedKey] ?? null)->toBe('refreshStatus'); -})->with('database-general-forms-with-narrow-refresh'); - function resolveLivewireListeners(object $component): array { // Livewire's HandlesEvents trait declares getListeners() as protected, @@ -101,7 +99,7 @@ function resolveLivewireListeners(object $component): array ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); })->with('database-status-info-components'); -it('reloads the mysql database model when refresh is called directly so ssl controls follow the latest status', function () { +it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -122,7 +120,7 @@ function resolveLivewireListeners(object $component): array 'destination_type' => $destination->getMorphClass(), ]); - $component = Livewire::test(MysqlGeneral::class, ['database' => $database]) + $component = Livewire::test(MysqlStatusInfo::class, ['database' => $database]) ->assertDontSee('Database should be stopped to change this settings.'); $database->fill(['status' => 'running:healthy'])->save(); @@ -161,36 +159,6 @@ function resolveLivewireListeners(object $component): array ->and($component->get('ip'))->toBe('203.0.113.42'); }); -it('preserves typed input on the postgres form when refreshStatus runs', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->first(); - $project = Project::factory()->create(['team_id' => $this->team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - - $database = StandalonePostgresql::create([ - 'name' => 'persisted-name', - 'image' => 'postgres:16', - 'postgres_user' => 'postgres', - 'postgres_password' => 'password', - 'postgres_db' => 'postgres', - 'status' => 'exited:unhealthy', - 'enable_ssl' => false, - 'is_log_drain_enabled' => false, - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); - - $component = Livewire::test(PostgresqlGeneral::class, ['database' => $database]) - ->set('name', 'user-was-typing-here') - ->set('portsMappings', '5433:5432'); - - $component->call('refreshStatus'); - - expect($component->get('name'))->toBe('user-was-typing-here') - ->and($component->get('portsMappings'))->toBe('5433:5432'); -}); - it('shows the redis ssl gate hint after the sibling is refreshed', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); From 7a3fcd37d5b5057523aa5107d986f209b29d5403 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 21 May 2026 10:24:49 +0000 Subject: [PATCH 035/122] fix(livewire): scope DatabaseProxyStopped to proxy fields, harden status trait Clickhouse, Dragonfly, and Keydb still called syncData() inside the DatabaseProxyStopped broadcast handler, clobbering in-progress edits to name/description/credentials. Refresh only is_public/public_port/ public_port_timeout instead, matching the pattern used elsewhere. Also null-guard HasDatabaseStatusInfo::getListeners() against an absent Auth::user()/currentTeam(), add explicit return types on getListeners() and render(), and convert inline comments in the SSL refresh test to a PHPDoc block. --- .../Project/Database/Clickhouse/General.php | 7 +++-- .../Project/Database/Dragonfly/General.php | 7 +++-- .../Project/Database/Keydb/General.php | 7 +++-- app/Traits/HasDatabaseStatusInfo.php | 26 ++++++++++++------- .../Feature/DatabaseSslStatusRefreshTest.php | 8 +++--- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index b5c0ffff4..857300926 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -192,9 +192,12 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 5f57693b1..01a474761 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -184,9 +184,12 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1c5c828a3..6031cb7ac 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -189,9 +189,12 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php index 98c939b7e..e46cccf0c 100644 --- a/app/Traits/HasDatabaseStatusInfo.php +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -5,6 +5,7 @@ use App\Helpers\SslHelper; use Carbon\Carbon; use Exception; +use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; /** @@ -51,16 +52,23 @@ protected function showPublicUrlPlaceholder(): bool return false; } - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $listeners = ['databaseUpdated' => 'refresh']; - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'databaseUpdated' => 'refresh', - ]; + $user = Auth::user(); + if (! $user) { + return $listeners; + } + + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh'; + + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh'; + } + + return $listeners; } public function mount(): void @@ -150,7 +158,7 @@ public function regenerateSslCertificate(): void } } - public function render() + public function render(): View { return view('livewire.project.database.status-info', [ 'label' => $this->databaseLabel(), diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index 7b0e4c0a3..7efb03789 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -78,11 +78,13 @@ ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); })->with('database-general-forms-without-broadcasts'); +/** + * Resolve a Livewire component's listeners regardless of whether the subclass + * exposes getListeners() publicly or only declares a $listeners array — the + * HandlesEvents trait keeps getListeners() protected by default. + */ function resolveLivewireListeners(object $component): array { - // Livewire's HandlesEvents trait declares getListeners() as protected, - // so subclasses that override it as public are callable directly, but - // subclasses that rely on $listeners are not. Reflection handles both. $method = new ReflectionMethod($component, 'getListeners'); $method->setAccessible(true); From de87624a72a5cea53b429283caf567126ae14849 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 21 May 2026 13:07:27 +0200 Subject: [PATCH 036/122] chore(deps): update composer lock dependencies --- composer.lock | 2521 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 1641 insertions(+), 880 deletions(-) diff --git a/composer.lock b/composer.lock index 5947a1588..24eb0bf73 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.374.2", + "version": "3.381.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "67b6b6210af47319c74c5666388d71bc1bc58276" + "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67b6b6210af47319c74c5666388d71bc1bc58276", - "reference": "67b6b6210af47319c74c5666388d71bc1bc58276", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/409208d62af0ddafbcb0af1a0bf514f5ffcaba92", + "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.374.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.381.5" }, - "time": "2026-03-27T18:05:55+00:00" + "time": "2026-05-20T18:16:01+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.4", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "3feed0e212b8412cc5d2612706744789b0615824" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", - "reference": "3feed0e212b8412cc5d2612706744789b0615824", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { @@ -208,9 +208,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2026-03-16T01:01:30+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "brick/math", @@ -1035,16 +1035,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.3", + "version": "v7.0.5", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, "require": { @@ -1052,6 +1052,7 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1091,10 +1092,10 @@ "php" ], "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" }, - "time": "2026-02-25T22:16:40+00:00" + "time": "2026-04-01T20:38:03+00:00" }, { "name": "fruitcake/php-cors", @@ -1231,16 +1232,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.10.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", "shasum": "" }, "require": { @@ -1258,8 +1259,9 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -1337,7 +1339,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.10.3" }, "funding": [ { @@ -1353,20 +1355,20 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-05-20T22:59:19+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", "shasum": "" }, "require": { @@ -1374,7 +1376,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -1420,7 +1422,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.4.1" }, "funding": [ { @@ -1436,20 +1438,20 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-05-20T22:57:30+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", "shasum": "" }, "require": { @@ -1464,9 +1466,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1537,7 +1539,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.10.1" }, "funding": [ { @@ -1553,7 +1555,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-05-20T09:27:36+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1703,28 +1705,29 @@ }, { "name": "laravel/fortify", - "version": "v1.36.2", + "version": "v1.37.2", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9" + "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/b36e0782e6f5f6cfbab34327895a63b7c4c031f9", - "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9", + "url": "https://api.github.com/repos/laravel/fortify/zipball/5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c", + "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1762,20 +1765,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-03-20T20:13:51+00:00" + "time": "2026-05-15T22:59:10+00:00" }, { "name": "laravel/framework", - "version": "v12.55.1", + "version": "v12.60.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" + "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", + "url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39", + "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39", "shasum": "" }, "require": { @@ -1816,8 +1819,8 @@ "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.33", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1984,20 +1987,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-18T14:28:59+00:00" + "time": "2026-05-20T11:48:19+00:00" }, { "name": "laravel/horizon", - "version": "v5.45.4", + "version": "v5.47.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6" + "reference": "be74bc494f7a244d74f1c8ad6552f9b8621f10c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/b2b32e3f6013081e0176307e9081cd085f0ad4d6", - "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6", + "url": "https://api.github.com/repos/laravel/horizon/zipball/be74bc494f7a244d74f1c8ad6552f9b8621f10c6", + "reference": "be74bc494f7a244d74f1c8ad6552f9b8621f10c6", "shasum": "" }, "require": { @@ -2062,9 +2065,9 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.45.4" + "source": "https://github.com/laravel/horizon/tree/v5.47.0" }, - "time": "2026-03-18T14:14:59+00:00" + "time": "2026-05-19T20:54:47+00:00" }, { "name": "laravel/mcp", @@ -2141,16 +2144,16 @@ }, { "name": "laravel/nightwatch", - "version": "v1.24.4", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/nightwatch.git", - "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8" + "reference": "d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8", - "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8", + "url": "https://api.github.com/repos/laravel/nightwatch/zipball/d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31", + "reference": "d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31", "shasum": "" }, "require": { @@ -2179,9 +2182,9 @@ "livewire/livewire": "^2.0|^3.0", "mockery/mockery": "^1.0", "mongodb/laravel-mongodb": "^4.0|^5.0", - "orchestra/testbench": "^8.0|^9.0|^10.0", - "orchestra/testbench-core": "^8.0|^9.0|^10.0", - "orchestra/workbench": "^8.0|^9.0|^10.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "orchestra/testbench-core": "^8.0|^9.0|^10.0|^11.0", + "orchestra/workbench": "^8.0|^9.0|^10.0|^11.0", "phpstan/phpstan": "^1.0", "phpunit/phpunit": "^10.0|^11.0|^12.0", "singlestoredb/singlestoredb-laravel": "^1.0|^2.0", @@ -2231,7 +2234,7 @@ "issues": "https://github.com/laravel/nightwatch/issues", "source": "https://github.com/laravel/nightwatch" }, - "time": "2026-03-18T23:25:05+00:00" + "time": "2026-05-21T01:59:31+00:00" }, { "name": "laravel/pail", @@ -2314,17 +2317,85 @@ "time": "2026-02-09T13:44:54+00:00" }, { - "name": "laravel/prompts", - "version": "v0.3.16", + "name": "laravel/passkeys", + "version": "v0.2.1", "source": { "type": "git", - "url": "https://github.com/laravel/prompts.git", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" + }, + "require-dev": { + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", "shasum": "" }, "require": { @@ -2368,22 +2439,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.16" + "source": "https://github.com/laravel/prompts/tree/v0.3.18" }, - "time": "2026-03-23T14:35:33+00:00" + "time": "2026-05-19T00:47:18+00:00" }, { "name": "laravel/sanctum", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", - "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", "shasum": "" }, "require": { @@ -2433,20 +2504,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-02-07T17:19:31+00:00" + "time": "2026-04-30T11:46:25+00:00" }, { "name": "laravel/sentinel", - "version": "v1.0.1", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/laravel/sentinel.git", - "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" + "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", - "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1", + "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1", "shasum": "" }, "require": { @@ -2465,9 +2536,6 @@ "providers": [ "Laravel\\Sentinel\\SentinelServiceProvider" ] - }, - "branch-alias": { - "dev-main": "1.x-dev" } }, "autoload": { @@ -2490,22 +2558,22 @@ } ], "support": { - "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + "source": "https://github.com/laravel/sentinel/tree/v1.1.0" }, - "time": "2026-02-12T13:32:54+00:00" + "time": "2026-03-24T14:03:38+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.13", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", "shasum": "" }, "require": { @@ -2553,20 +2621,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-16T14:03:50+00:00" }, { "name": "laravel/socialite", - "version": "v5.26.0", + "version": "v5.27.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0" + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/1d26f0c653a5f0e88859f4197830a29fe0cc59d0", - "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0", + "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", "shasum": "" }, "require": { @@ -2625,7 +2693,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-03-24T18:37:47+00:00" + "time": "2026-04-24T14:05:47+00:00" }, { "name": "laravel/tinker", @@ -3020,16 +3088,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -3097,26 +3165,26 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.32.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/0c62fdac907791d8649ad3c61cb7a77628344fb8", + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8", "shasum": "" }, "require": { - "aws/aws-sdk-php": "^3.295.10", + "aws/aws-sdk-php": "^3.371.5", "league/flysystem": "^3.10.0", "league/mime-type-detection": "^1.0.0", "php": "^8.0.2" @@ -3152,9 +3220,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.34.0" }, - "time": "2026-02-25T16:46:44+00:00" + "time": "2026-05-04T08:24:00+00:00" }, { "name": "league/flysystem-local", @@ -3570,16 +3638,16 @@ }, { "name": "livewire/livewire", - "version": "v3.7.11", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6" + "reference": "d81d269243c3f18d302663c0ce5672990df08ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6", + "url": "https://api.github.com/repos/livewire/livewire/zipball/d81d269243c3f18d302663c0ce5672990df08ca1", + "reference": "d81d269243c3f18d302663c0ce5672990df08ca1", "shasum": "" }, "require": { @@ -3634,7 +3702,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.11" + "source": "https://github.com/livewire/livewire/tree/v3.8.0" }, "funding": [ { @@ -3642,7 +3710,7 @@ "type": "github" } ], - "time": "2026-02-26T00:58:19+00:00" + "time": "2026-04-30T23:56:43+00:00" }, { "name": "log1x/laravel-webfonts", @@ -4025,16 +4093,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -4126,7 +4194,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -4197,16 +4265,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -4282,9 +4350,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", @@ -4681,102 +4749,6 @@ }, "time": "2020-10-15T08:29:30+00:00" }, - { - "name": "paragonie/sodium_compat", - "version": "v2.5.0", - "source": { - "type": "git", - "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/4714da6efdc782c06690bc72ce34fae7941c2d9f", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f", - "shasum": "" - }, - "require": { - "php": "^8.1", - "php-64bit": "*" - }, - "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^7|^8|^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" - }, - "suggest": { - "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "files": [ - "autoload.php" - ], - "psr-4": { - "ParagonIE\\Sodium\\": "namespaced/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "ISC" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com" - }, - { - "name": "Frank Denis", - "email": "jedisct1@pureftpd.org" - } - ], - "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", - "keywords": [ - "Authentication", - "BLAKE2b", - "ChaCha20", - "ChaCha20-Poly1305", - "Chapoly", - "Curve25519", - "Ed25519", - "EdDSA", - "Edwards-curve Digital Signature Algorithm", - "Elliptic Curve Diffie-Hellman", - "Poly1305", - "Pure-PHP cryptography", - "RFC 7748", - "RFC 8032", - "Salpoly", - "Salsa20", - "X25519", - "XChaCha20-Poly1305", - "XSalsa20-Poly1305", - "Xchacha20", - "Xsalsa20", - "aead", - "cryptography", - "ecdh", - "elliptic curve", - "elliptic curve cryptography", - "encryption", - "libsodium", - "php", - "public-key cryptography", - "secret-key cryptography", - "side-channel resistant" - ], - "support": { - "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.0" - }, - "time": "2025-12-30T16:12:18+00:00" - }, { "name": "php-di/invoker", "version": "2.3.7", @@ -4905,78 +4877,6 @@ ], "time": "2025-08-16T11:10:48+00:00" }, - { - "name": "phpdocumentor/reflection", - "version": "6.4.4", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/Reflection.git", - "reference": "5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c", - "reference": "5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "nikic/php-parser": "~4.18 || ^5.0", - "php": "8.1.*|8.2.*|8.3.*|8.4.*|8.5.*", - "phpdocumentor/reflection-common": "^2.1", - "phpdocumentor/reflection-docblock": "^5", - "phpdocumentor/type-resolver": "^1.4", - "symfony/polyfill-php80": "^1.28", - "webmozart/assert": "^1.7" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/coding-standard": "^13.0", - "eliashaeussler/phpunit-attributes": "^1.8", - "mikey179/vfsstream": "~1.2", - "mockery/mockery": "~1.6.0", - "phpspec/prophecy-phpunit": "^2.4", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^10.5.53", - "psalm/phar": "^6.0", - "rector/rector": "^1.0.0", - "squizlabs/php_codesniffer": "^3.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-5.x": "5.3.x-dev", - "dev-6.x": "6.0.x-dev" - } - }, - "autoload": { - "files": [ - "src/php-parser/Modifiers.php" - ], - "psr-4": { - "phpDocumentor\\": "src/phpDocumentor" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Reflection library to do Static Analysis for PHP Projects", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/Reflection/issues", - "source": "https://github.com/phpDocumentor/Reflection/tree/6.4.4" - }, - "time": "2025-11-25T21:21:18+00:00" - }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -5032,16 +4932,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.7", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -5049,8 +4949,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -5060,7 +4960,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -5090,44 +4991,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2026-03-18T20:47:46+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -5148,9 +5049,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpoption/phpoption", @@ -6136,23 +6037,22 @@ }, { "name": "pusher/pusher-php-server", - "version": "7.2.7", + "version": "7.2.8", "source": { "type": "git", "url": "https://github.com/pusher/pusher-http-php.git", - "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7" + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7", - "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2", + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "guzzlehttp/guzzle": "^7.2", - "paragonie/sodium_compat": "^1.6|^2.0", "php": "^7.3|^8.0", "psr/log": "^1.0|^2.0|^3.0" }, @@ -6191,9 +6091,9 @@ ], "support": { "issues": "https://github.com/pusher/pusher-http-php/issues", - "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7" + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.8" }, - "time": "2025-01-06T10:56:20+00:00" + "time": "2026-05-18T13:11:36+00:00" }, { "name": "ralouphie/getallheaders", @@ -6521,16 +6421,16 @@ }, { "name": "sentry/sentry", - "version": "4.23.0", + "version": "4.27.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66" + "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/121a674d5fffcdb8e414b75c1b76edba8e592b66", - "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1f0544cff8443ac1d25d6521487118e28381a1c2", + "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2", "shasum": "" }, "require": { @@ -6547,6 +6447,7 @@ "raven/raven": "*" }, "require-dev": { + "carthage-software/mago": "^1.13.3", "friendsofphp/php-cs-fixer": "^3.4", "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", @@ -6562,6 +6463,7 @@ "spiral/roadrunner-worker": "^3.6" }, "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." }, "type": "library", @@ -6598,7 +6500,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.23.0" + "source": "https://github.com/getsentry/sentry-php/tree/4.27.0" }, "funding": [ { @@ -6610,20 +6512,20 @@ "type": "custom" } ], - "time": "2026-03-23T13:15:52+00:00" + "time": "2026-05-06T14:32:16+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.24.0", + "version": "4.25.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d" + "reference": "67efbdd74a752fcc1038676986b055a4df7d5084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/f823bd85e38e06cb4f1b7a82d48a2fc95320b31d", - "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/67efbdd74a752fcc1038676986b055a4df7d5084", + "reference": "67efbdd74a752fcc1038676986b055a4df7d5084", "shasum": "" }, "require": { @@ -6689,7 +6591,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.24.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.25.1" }, "funding": [ { @@ -6701,7 +6603,7 @@ "type": "custom" } ], - "time": "2026-03-24T10:33:54+00:00" + "time": "2026-05-05T09:22:46+00:00" }, { "name": "socialiteproviders/authentik", @@ -7337,29 +7239,31 @@ }, { "name": "spatie/laravel-data", - "version": "4.20.1", + "version": "4.23.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad" + "reference": "230543769c996e407fec2873930626aed7dd0d3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/5490cb15de6fc8b35a8cd2f661fac072d987a1ad", - "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/230543769c996e407fec2873930626aed7dd0d3b", + "reference": "230543769c996e407fec2873930626aed7dd0d3b", "shasum": "" }, "require": { "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "phpdocumentor/reflection": "^6.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/reflection-docblock": "^5.3 || ^6.0", + "phpdocumentor/type-resolver": "^1.7 || ^2.0", "spatie/laravel-package-tools": "^1.9.0", "spatie/php-structure-discoverer": "^2.0" }, "require-dev": { "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", - "inertiajs/inertia-laravel": "^2.0", + "inertiajs/inertia-laravel": "^2.0|^3.0", "livewire/livewire": "^3.0|^4.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63|^3.0", @@ -7407,7 +7311,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.20.1" + "source": "https://github.com/spatie/laravel-data/tree/4.23.0" }, "funding": [ { @@ -7415,7 +7319,7 @@ "type": "github" } ], - "time": "2026-03-18T07:44:01+00:00" + "time": "2026-05-08T14:41:13+00:00" }, { "name": "spatie/laravel-markdown", @@ -7495,16 +7399,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.93.0", + "version": "1.93.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" + "reference": "d5552849801f2642aea710557463234b59ef65eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", - "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", "shasum": "" }, "require": { @@ -7544,7 +7448,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" }, "funding": [ { @@ -7552,20 +7456,20 @@ "type": "github" } ], - "time": "2026-02-21T12:49:54+00:00" + "time": "2026-05-19T14:06:37+00:00" }, { "name": "spatie/laravel-ray", - "version": "1.43.7", + "version": "1.43.9", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ray.git", - "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3" + "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/d550d0b5bf87bb1b1668089f3c843e786ee522d3", - "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3", + "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/85137a6ea1d3ecd5ad3adcb43512fff9a5529e72", + "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72", "shasum": "" }, "require": { @@ -7584,7 +7488,7 @@ "require-dev": { "guzzlehttp/guzzle": "^7.3", "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", - "laravel/pint": "^1.27", + "laravel/pint": "^1.29", "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "pestphp/pest": "^1.22|^2.0|^3.0|^4.0", "phpstan/phpstan": "^1.10.57|^2.0.2", @@ -7629,7 +7533,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.43.7" + "source": "https://github.com/spatie/laravel-ray/tree/1.43.9" }, "funding": [ { @@ -7641,7 +7545,7 @@ "type": "other" } ], - "time": "2026-03-06T08:19:04+00:00" + "time": "2026-04-28T06:07:04+00:00" }, { "name": "spatie/laravel-schemaless-attributes", @@ -7772,16 +7676,16 @@ }, { "name": "spatie/php-structure-discoverer", - "version": "2.4.0", + "version": "2.4.2", "source": { "type": "git", "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146" + "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/9a53c79b48fca8b6d15faa8cbba47cc430355146", - "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/10cd4e0018450d23e2bd8f8472569ad0c445c0fc", + "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc", "shasum": "" }, "require": { @@ -7839,7 +7743,7 @@ ], "support": { "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.0" + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.2" }, "funding": [ { @@ -7847,20 +7751,20 @@ "type": "github" } ], - "time": "2026-02-21T15:57:15+00:00" + "time": "2026-04-28T06:26:02+00:00" }, { "name": "spatie/ray", - "version": "1.47.0", + "version": "1.48.0", "source": { "type": "git", "url": "https://github.com/spatie/ray.git", - "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce" + "reference": "974ac9c6e315033ab8ace883d60e094522f88ede" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ray/zipball/3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", - "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", + "url": "https://api.github.com/repos/spatie/ray/zipball/974ac9c6e315033ab8ace883d60e094522f88ede", + "reference": "974ac9c6e315033ab8ace883d60e094522f88ede", "shasum": "" }, "require": { @@ -7920,7 +7824,7 @@ ], "support": { "issues": "https://github.com/spatie/ray/issues", - "source": "https://github.com/spatie/ray/tree/1.47.0" + "source": "https://github.com/spatie/ray/tree/1.48.0" }, "funding": [ { @@ -7932,20 +7836,20 @@ "type": "other" } ], - "time": "2026-02-20T20:42:26+00:00" + "time": "2026-03-31T12:44:31+00:00" }, { "name": "spatie/shiki-php", - "version": "2.3.3", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/spatie/shiki-php.git", - "reference": "9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b" + "reference": "b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/shiki-php/zipball/9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b", - "reference": "9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b", + "url": "https://api.github.com/repos/spatie/shiki-php/zipball/b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba", + "reference": "b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba", "shasum": "" }, "require": { @@ -7989,7 +7893,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/shiki-php/tree/2.3.3" + "source": "https://github.com/spatie/shiki-php/tree/2.4.0" }, "funding": [ { @@ -7997,7 +7901,7 @@ "type": "github" } ], - "time": "2026-02-01T09:30:04+00:00" + "time": "2026-04-27T14:27:52+00:00" }, { "name": "spatie/url", @@ -8061,6 +7965,187 @@ ], "time": "2024-03-08T11:35:19+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.2.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-04-01T12:15:20+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, { "name": "stevebauman/purify", "version": "v6.3.2", @@ -8188,16 +8273,16 @@ }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "shasum": "" }, "require": { @@ -8241,7 +8326,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { @@ -8261,20 +8346,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", "shasum": "" }, "require": { @@ -8339,7 +8424,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v7.4.11" }, "funding": [ { @@ -8359,20 +8444,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.6", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262" + "reference": "3665cfade90565430909b906394c73c8739e57d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", "shasum": "" }, "require": { @@ -8408,7 +8493,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" }, "funding": [ { @@ -8428,20 +8513,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -8454,7 +8539,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8479,7 +8564,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -8490,25 +8575,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", "shasum": "" }, "require": { @@ -8557,7 +8646,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" }, "funding": [ { @@ -8577,20 +8666,20 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", "shasum": "" }, "require": { @@ -8642,7 +8731,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" }, "funding": [ { @@ -8662,20 +8751,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -8689,7 +8778,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8722,7 +8811,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -8733,25 +8822,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/filesystem", - "version": "v8.0.6", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", "shasum": "" }, "require": { @@ -8788,7 +8881,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" }, "funding": [ { @@ -8808,20 +8901,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-05-11T16:39:47+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -8856,7 +8949,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -8876,20 +8969,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", "shasum": "" }, "require": { @@ -8938,7 +9031,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" }, "funding": [ { @@ -8958,20 +9051,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", "shasum": "" }, "require": { @@ -9057,7 +9150,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.12" }, "funding": [ { @@ -9077,20 +9170,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-05-20T09:27:11+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", "shasum": "" }, "require": { @@ -9141,7 +9234,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.12" }, "funding": [ { @@ -9161,20 +9254,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", "shasum": "" }, "require": { @@ -9230,7 +9323,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v7.4.12" }, "funding": [ { @@ -9250,20 +9343,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/options-resolver", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", "shasum": "" }, "require": { @@ -9301,7 +9394,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" }, "funding": [ { @@ -9321,20 +9414,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:55:31+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -9384,7 +9477,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -9404,20 +9497,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-iconv", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa" + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/5f3b930437ae03ae5dff61269024d8ea1b3774aa", - "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57", "shasum": "" }, "require": { @@ -9468,7 +9561,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-iconv/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0" }, "funding": [ { @@ -9488,20 +9581,20 @@ "type": "tidelift" } ], - "time": "2024-09-17T14:58:18+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -9550,7 +9643,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -9570,11 +9663,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -9637,7 +9730,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" }, "funding": [ { @@ -9661,7 +9754,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -9722,7 +9815,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -9746,16 +9839,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -9807,7 +9900,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -9827,20 +9920,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -9891,7 +9984,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -9911,20 +10004,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -9971,7 +10064,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -9991,20 +10084,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -10051,7 +10144,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -10071,20 +10164,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -10131,7 +10224,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -10151,20 +10244,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -10214,7 +10307,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -10234,20 +10327,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", "shasum": "" }, "require": { @@ -10279,7 +10372,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.11" }, "funding": [ { @@ -10299,20 +10392,187 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-05-11T16:55:21+00:00" }, { - "name": "symfony/psr-http-message-bridge", - "version": "v8.0.4", + "name": "symfony/property-access", + "version": "v8.0.8", "source": { "type": "git", - "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531" + "url": "https://github.com/symfony/property-access.git", + "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531", - "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531", + "url": "https://api.github.com/repos/symfony/property-access/zipball/704c7808116fcdd67327db7b17de56b8ef6169e4", + "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/c21711980653360d6ef5c26d0f9ca6f58a1135c6", + "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19", "shasum": "" }, "require": { @@ -10366,7 +10626,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8" }, "funding": [ { @@ -10386,20 +10646,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:40:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", "shasum": "" }, "require": { @@ -10451,7 +10711,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v7.4.12" }, "funding": [ { @@ -10471,20 +10731,118 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.6.1", + "name": "symfony/serializer", + "version": "v8.0.10", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "url": "https://github.com/symfony/serializer.git", + "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/serializer/zipball/72ed7e1475790714f07c3a59bd01fd32cd022fdf", + "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<7.4.2|>=8.0,<8.0.2", + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^7.4.2|^8.0.2", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-04T13:41:39+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -10502,7 +10860,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -10538,7 +10896,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -10558,20 +10916,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/stopwatch", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942" + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942", - "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", "shasum": "" }, "require": { @@ -10604,7 +10962,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.0.0" + "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" }, "funding": [ { @@ -10624,20 +10982,20 @@ "type": "tidelift" } ], - "time": "2025-08-04T07:36:47+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -10694,7 +11052,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -10714,20 +11072,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", "shasum": "" }, "require": { @@ -10787,7 +11145,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.0.10" }, "funding": [ { @@ -10807,20 +11165,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-05-06T11:30:54+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -10833,7 +11191,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -10869,7 +11227,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -10889,20 +11247,102 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { - "name": "symfony/uid", - "version": "v7.4.4", + "name": "symfony/type-info", + "version": "v8.0.9", "source": { "type": "git", - "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "url": "https://github.com/symfony/type-info.git", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/type-info/zipball/08723aceb8c3271e8cb3db8b2565728b0c88e866", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T15:02:55+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", "shasum": "" }, "require": { @@ -10947,7 +11387,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.9" }, "funding": [ { @@ -10967,20 +11407,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-04-30T15:19:22+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -11034,7 +11474,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -11054,20 +11494,20 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", "shasum": "" }, "require": { @@ -11110,7 +11550,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.12" }, "funding": [ { @@ -11130,7 +11570,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11343,23 +11783,23 @@ }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -11389,7 +11829,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -11413,27 +11853,184 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" }, { - "name": "webmozart/assert", - "version": "1.12.1", + "name": "web-auth/cose-lib", + "version": "4.5.2", "source": { "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.5.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-03T09:49:50+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.3", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-17T19:04:30+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -11442,8 +12039,12 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { - "dev-master": "1.10-dev" + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -11459,6 +12060,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -11469,9 +12074,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-05-20T13:07:01+00:00" }, { "name": "yosymfony/parser-utils", @@ -12199,16 +12804,16 @@ }, { "name": "amphp/hpack", - "version": "v3.2.1", + "version": "v3.2.2", "source": { "type": "git", "url": "https://github.com/amphp/hpack.git", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4", "shasum": "" }, "require": { @@ -12217,7 +12822,7 @@ "require-dev": { "amphp/php-cs-fixer-config": "^2", "http2jp/hpack-test-case": "^1", - "nikic/php-fuzzer": "^0.0.10", + "nikic/php-fuzzer": "^0.0.11", "phpunit/phpunit": "^7 | ^8 | ^9" }, "type": "library", @@ -12261,7 +12866,7 @@ ], "support": { "issues": "https://github.com/amphp/hpack/issues", - "source": "https://github.com/amphp/hpack/tree/v3.2.1" + "source": "https://github.com/amphp/hpack/tree/v3.2.2" }, "funding": [ { @@ -12269,7 +12874,7 @@ "type": "github" } ], - "time": "2024-03-21T19:00:16+00:00" + "time": "2026-05-03T19:28:59+00:00" }, { "name": "amphp/http", @@ -12337,16 +12942,16 @@ }, { "name": "amphp/http-client", - "version": "v5.3.4", + "version": "v5.3.6", "source": { "type": "git", "url": "https://github.com/amphp/http-client.git", - "reference": "75ad21574fd632594a2dd914496647816d5106bc" + "reference": "ca155026acafa74a612d776a97202d53077fee86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", - "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86", + "reference": "ca155026acafa74a612d776a97202d53077fee86", "shasum": "" }, "require": { @@ -12374,9 +12979,8 @@ "amphp/phpunit-util": "^3", "ext-json": "*", "kelunik/link-header-rfc5988": "^1", - "laminas/laminas-diactoros": "^2.3", "phpunit/phpunit": "^9", - "psalm/phar": "~5.23" + "psalm/phar": "6.16.1" }, "suggest": { "amphp/file": "Required for file request bodies and HTTP archive logging", @@ -12423,7 +13027,7 @@ ], "support": { "issues": "https://github.com/amphp/http-client/issues", - "source": "https://github.com/amphp/http-client/tree/v5.3.4" + "source": "https://github.com/amphp/http-client/tree/v5.3.6" }, "funding": [ { @@ -12431,20 +13035,20 @@ "type": "github" } ], - "time": "2025-08-16T20:41:23+00:00" + "time": "2026-05-15T23:29:38+00:00" }, { "name": "amphp/http-server", - "version": "v3.4.4", + "version": "v3.4.5", "source": { "type": "git", "url": "https://github.com/amphp/http-server.git", - "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef" + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef", - "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef", + "url": "https://api.github.com/repos/amphp/http-server/zipball/ae0fd01e16aba336247852df0c3f8c649a31896d", + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d", "shasum": "" }, "require": { @@ -12471,7 +13075,7 @@ "league/uri-components": "^7.1", "monolog/monolog": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "~5.23" + "psalm/phar": "6.16.1" }, "suggest": { "ext-zlib": "Allows GZip compression of response bodies" @@ -12520,7 +13124,7 @@ ], "support": { "issues": "https://github.com/amphp/http-server/issues", - "source": "https://github.com/amphp/http-server/tree/v3.4.4" + "source": "https://github.com/amphp/http-server/tree/v3.4.5" }, "funding": [ { @@ -12528,7 +13132,7 @@ "type": "github" } ], - "time": "2026-02-08T18:16:29+00:00" + "time": "2026-05-01T03:55:07+00:00" }, { "name": "amphp/parser", @@ -12594,16 +13198,16 @@ }, { "name": "amphp/pipeline", - "version": "v1.2.3", + "version": "v1.2.4", "source": { "type": "git", "url": "https://github.com/amphp/pipeline.git", - "reference": "7b52598c2e9105ebcddf247fc523161581930367" + "reference": "a044733e080940d1483f56caff0c412ad6982776" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", - "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", "shasum": "" }, "require": { @@ -12615,7 +13219,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12649,7 +13253,7 @@ ], "support": { "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + "source": "https://github.com/amphp/pipeline/tree/v1.2.4" }, "funding": [ { @@ -12657,7 +13261,7 @@ "type": "github" } ], - "time": "2025-03-16T16:33:53+00:00" + "time": "2026-05-06T05:37:57+00:00" }, { "name": "amphp/process", @@ -12729,24 +13333,27 @@ }, { "name": "amphp/serialization", - "version": "v1.0.0", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12781,22 +13388,28 @@ ], "support": { "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, - "time": "2020-03-25T21:39:07+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" }, { "name": "amphp/socket", - "version": "v2.3.1", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", "shasum": "" }, "require": { @@ -12805,17 +13418,17 @@ "amphp/dns": "^2", "ext-openssl": "*", "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", + "league/uri": "^7", + "league/uri-interfaces": "^7", "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" + "revolt/event-loop": "^1" }, "require-dev": { "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "amphp/process": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12859,7 +13472,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" + "source": "https://github.com/amphp/socket/tree/v2.4.0" }, "funding": [ { @@ -12867,7 +13480,7 @@ "type": "github" } ], - "time": "2024-04-21T14:33:03+00:00" + "time": "2026-04-19T15:09:56+00:00" }, { "name": "amphp/sync", @@ -13196,16 +13809,16 @@ }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -13229,7 +13842,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.44", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", @@ -13273,7 +13886,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -13285,7 +13898,152 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" }, { "name": "daverandom/libdns", @@ -13333,16 +14091,16 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "807840ceb09de6764cbfcce0719108d044a459a9" + "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/807840ceb09de6764cbfcce0719108d044a459a9", - "reference": "807840ceb09de6764cbfcce0719108d044a459a9", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/3c1c13f335b3b4d1a1f944a8ea194020044871ed", + "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed", "shasum": "" }, "require": { @@ -13363,9 +14121,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.2.0" + "source": "https://github.com/driftingly/rector-laravel/tree/2.3.0" }, - "time": "2026-03-19T17:24:38+00:00" + "time": "2026-04-08T10:52:44+00:00" }, { "name": "fakerphp/faker", @@ -13673,16 +14431,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.1", + "version": "v2.4.8", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506" + "reference": "d11d720cf9537f8d236a11d973e99563a598ec9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506", - "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506", + "url": "https://api.github.com/repos/laravel/boost/zipball/d11d720cf9537f8d236a11d973e99563a598ec9c", + "reference": "d11d720cf9537f8d236a11d973e99563a598ec9c", "shasum": "" }, "require": { @@ -13691,7 +14449,7 @@ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/mcp": "^0.5.1|^0.6.0|~0.7.0,<0.7.1", "laravel/prompts": "^0.3.10", "laravel/roster": "^0.5.0", "php": "^8.2" @@ -13735,20 +14493,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-25T16:37:40+00:00" + "time": "2026-05-19T20:09:50+00:00" }, { "name": "laravel/dusk", - "version": "v8.5.0", + "version": "v8.6.0", "source": { "type": "git", "url": "https://github.com/laravel/dusk.git", - "reference": "f9f75666bed46d1ebca13792447be6e753f4e790" + "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/f9f75666bed46d1ebca13792447be6e753f4e790", - "reference": "f9f75666bed46d1ebca13792447be6e753f4e790", + "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", + "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", "shasum": "" }, "require": { @@ -13807,22 +14565,22 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.5.0" + "source": "https://github.com/laravel/dusk/tree/v8.6.0" }, - "time": "2026-03-21T11:50:49+00:00" + "time": "2026-04-15T14:50:40+00:00" }, { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -13833,14 +14591,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -13877,7 +14635,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "laravel/roster", @@ -13942,16 +14700,16 @@ }, { "name": "laravel/telescope", - "version": "v5.19.0", + "version": "v5.20.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b" + "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b", - "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b", + "url": "https://api.github.com/repos/laravel/telescope/zipball/38ec6e6006a67e05e0c476c5f8ef3550b72e43d8", + "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8", "shasum": "" }, "require": { @@ -14005,9 +14763,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.19.0" + "source": "https://github.com/laravel/telescope/tree/v5.20.0" }, - "time": "2026-03-24T18:37:14+00:00" + "time": "2026-04-06T12:52:26+00:00" }, { "name": "league/uri-components", @@ -14238,23 +14996,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -14262,12 +15020,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -14330,45 +15088,47 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "pestphp/pest", - "version": "v4.4.3", + "version": "v4.7.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495" + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/e6ab897594312728ef2e32d586cb4f6780b1b495", - "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495", + "url": "https://api.github.com/repos/pestphp/pest/zipball/2fc75cfcf03c041c804778fa894282234adc3c66", + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66", "shasum": "" }, "require": { - "brianium/paratest": "^7.19.2", - "nunomaduro/collision": "^8.9.1", + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", - "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.14", - "symfony/process": "^7.4.5|^8.0.5" + "phpunit/phpunit": "^12.5.24", + "symfony/process": "^7.4.8|^8.0.8" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.14", + "phpunit/phpunit": ">12.5.24", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { + "mrpunyapal/peststan": "^0.2.9", "pestphp/pest-dev-tools": "^4.1.0", - "pestphp/pest-plugin-browser": "^4.3.0", - "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.21" + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.22" }, "bin": [ "bin/pest" @@ -14395,6 +15155,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -14434,7 +15195,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.4.3" + "source": "https://github.com/pestphp/pest/tree/v4.7.0" }, "funding": [ { @@ -14446,7 +15207,7 @@ "type": "github" } ], - "time": "2026-03-21T13:14:39+00:00" + "time": "2026-05-03T16:09:32+00:00" }, { "name": "pestphp/pest-plugin", @@ -14520,26 +15281,26 @@ }, { "name": "pestphp/pest-plugin-arch", - "version": "v4.0.0", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "ta-tikoma/phpunit-architecture-test": "^0.8.5" + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "pestphp/pest": "^4.0.0", - "pestphp/pest-dev-tools": "^4.0.0" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", "extra": { @@ -14574,7 +15335,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, "funding": [ { @@ -14586,20 +15347,20 @@ "type": "github" } ], - "time": "2025-08-20T13:10:51+00:00" + "time": "2026-04-10T17:20:19+00:00" }, { "name": "pestphp/pest-plugin-browser", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-browser.git", - "reference": "48bc408033281974952a6b296592cef3b920a2db" + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db", - "reference": "48bc408033281974952a6b296592cef3b920a2db", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/b6e76d3e4a2f81da9f050ec54be2a29b402287c4", + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4", "shasum": "" }, "require": { @@ -14607,20 +15368,20 @@ "amphp/http-server": "^3.4.4", "amphp/websocket-client": "^2.0.2", "ext-sockets": "*", - "pestphp/pest": "^4.3.2", + "pestphp/pest": "^4.4.5", "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "symfony/process": "^7.4.5|^8.0.5" + "symfony/process": "^7.4.8|^8.0.5" }, "require-dev": { "ext-pcntl": "*", "ext-posix": "*", - "livewire/livewire": "^3.7.10", - "nunomaduro/collision": "^8.9.0", - "orchestra/testbench": "^10.9.0", + "livewire/livewire": "^3.7.15", + "nunomaduro/collision": "^8.9.3", + "orchestra/testbench": "^10.11.0", "pestphp/pest-dev-tools": "^4.1.0", - "pestphp/pest-plugin-laravel": "^4.0", - "pestphp/pest-plugin-type-coverage": "^4.0.3" + "pestphp/pest-plugin-laravel": "^4.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4" }, "type": "library", "extra": { @@ -14653,7 +15414,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.1" }, "funding": [ { @@ -14669,7 +15430,7 @@ "type": "patreon" } ], - "time": "2026-02-17T14:54:40+00:00" + "time": "2026-04-08T21:04:12+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -15063,11 +15824,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.55", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", + "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", "shasum": "" }, "require": { @@ -15112,20 +15873,20 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-05-18T11:57:34+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { @@ -15134,7 +15895,6 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", "sebastian/environment": "^8.0.3", @@ -15181,7 +15941,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { @@ -15201,7 +15961,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", @@ -15462,16 +16222,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.24", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", "shasum": "" }, "require": { @@ -15485,15 +16245,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.6", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/comparator": "^7.1.6", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.1.0", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -15540,49 +16300,33 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-05-01T04:21:04+00:00" }, { "name": "rector/rector", - "version": "2.3.9", + "version": "2.4.4", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4" + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/917842143fd9f5331a2adefc214b8d7143bd32c4", - "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/4661c582a20f03df585d2e3fdc4af1b83d67a091", + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.40" + "phpstan/phpstan": "^2.1.48" }, "conflict": { "rector/rector-doctrine": "*", @@ -15616,7 +16360,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.9" + "source": "https://github.com/rectorphp/rector/tree/2.4.4" }, "funding": [ { @@ -15624,20 +16368,20 @@ "type": "github" } ], - "time": "2026-03-16T09:43:55+00:00" + "time": "2026-05-20T19:30:21+00:00" }, { "name": "revolt/event-loop", - "version": "v1.0.8", + "version": "v1.0.9", "source": { "type": "git", "url": "https://github.com/revoltphp/event-loop.git", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + "reference": "44061cf513e53c6200372fc935ac42271566295d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", "shasum": "" }, "require": { @@ -15647,7 +16391,7 @@ "ext-json": "*", "jetbrains/phpstorm-stubs": "^2019.3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.15" + "psalm/phar": "6.16.*" }, "type": "library", "extra": { @@ -15694,29 +16438,29 @@ ], "support": { "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" }, - "time": "2025-08-27T21:33:23+00:00" + "time": "2026-05-16T17:55:38+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -15745,7 +16489,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -15765,20 +16509,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -15786,10 +16530,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -15837,7 +16581,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -15857,7 +16601,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -15986,16 +16730,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.4", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { @@ -16010,7 +16754,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -16038,7 +16782,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { @@ -16058,29 +16802,29 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:05:40+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -16128,7 +16872,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -16148,7 +16892,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", @@ -16226,24 +16970,24 @@ }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -16272,15 +17016,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -16474,23 +17230,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -16519,7 +17275,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -16539,7 +17295,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -16597,20 +17353,21 @@ }, { "name": "serversideup/spin", - "version": "v3.1.1", + "version": "v3.2.3", "source": { "type": "git", "url": "https://github.com/serversideup/spin.git", - "reference": "5da5b5485b03e4f75d501b93b8a7e8ab973157cd" + "reference": "764b09fdfe83249117abfd913af4103b75edc586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serversideup/spin/zipball/5da5b5485b03e4f75d501b93b8a7e8ab973157cd", - "reference": "5da5b5485b03e4f75d501b93b8a7e8ab973157cd", + "url": "https://api.github.com/repos/serversideup/spin/zipball/764b09fdfe83249117abfd913af4103b75edc586", + "reference": "764b09fdfe83249117abfd913af4103b75edc586", "shasum": "" }, "bin": [ - "bin/spin" + "bin/spin", + "bin/spin-mcp-wait.sh" ], "type": "library", "notification-url": "https://packagist.org/downloads/", @@ -16630,7 +17387,7 @@ "description": "Replicate your production environment locally using Docker. Just run \"spin up\". It's really that easy.", "support": { "issues": "https://github.com/serversideup/spin/issues", - "source": "https://github.com/serversideup/spin/tree/v3.1.1" + "source": "https://github.com/serversideup/spin/tree/v3.2.3" }, "funding": [ { @@ -16638,7 +17395,7 @@ "type": "github" } ], - "time": "2025-11-06T19:13:57+00:00" + "time": "2026-04-16T21:33:58+00:00" }, { "name": "spatie/error-solutions", @@ -16716,16 +17473,16 @@ }, { "name": "spatie/flare-client-php", - "version": "1.11.0", + "version": "1.11.1", "source": { "type": "git", "url": "https://github.com/spatie/flare-client-php.git", - "reference": "fb3ffb946675dba811fbde9122224db2f84daca9" + "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/fb3ffb946675dba811fbde9122224db2f84daca9", - "reference": "fb3ffb946675dba811fbde9122224db2f84daca9", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/53f41b08a27cc039e1a8ed2be9a202e924f31bad", + "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad", "shasum": "" }, "require": { @@ -16773,7 +17530,7 @@ ], "support": { "issues": "https://github.com/spatie/flare-client-php/issues", - "source": "https://github.com/spatie/flare-client-php/tree/1.11.0" + "source": "https://github.com/spatie/flare-client-php/tree/1.11.1" }, "funding": [ { @@ -16781,7 +17538,7 @@ "type": "github" } ], - "time": "2026-03-17T08:06:16+00:00" + "time": "2026-05-15T09:31:32+00:00" }, { "name": "spatie/ignition", @@ -17015,16 +17772,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", "shasum": "" }, "require": { @@ -17092,7 +17849,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.9" }, "funding": [ { @@ -17112,20 +17869,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-04-29T13:25:15+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -17138,7 +17895,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -17174,7 +17931,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -17185,12 +17942,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", From 0c7fcffa018a87bb9369ace255e364e8c4dd4dda Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 21 May 2026 13:08:15 +0200 Subject: [PATCH 037/122] version update --- other/nightly/versions.json | 2 +- versions.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/other/nightly/versions.json b/other/nightly/versions.json index b40eafe2c..f3d826753 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -4,7 +4,7 @@ "version": "4.1.0" }, "nightly": { - "version": "4.0.0" + "version": "4.2.0" }, "helper": { "version": "1.0.14" diff --git a/versions.json b/versions.json index b40eafe2c..f3d826753 100644 --- a/versions.json +++ b/versions.json @@ -4,7 +4,7 @@ "version": "4.1.0" }, "nightly": { - "version": "4.0.0" + "version": "4.2.0" }, "helper": { "version": "1.0.14" From b124397613f52b2e9a2f23de7ccb6e5d2c9a7fdb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 21 May 2026 19:19:43 +0200 Subject: [PATCH 038/122] fix(schedule): prevent duplicate SSL certificate regeneration Run RegenerateSslCertJob on one server only and add coverage to ensure scheduled production jobs use onOneServer. --- app/Console/Kernel.php | 2 +- tests/Feature/ScheduleOnOneServerTest.php | 38 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/ScheduleOnOneServerTest.php diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 75ec31ae0..3ec59adb3 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -78,7 +78,7 @@ protected function schedule(Schedule $schedule): void // Scheduled Jobs (Backups & Tasks) $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); - $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily(); + $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer(); $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php new file mode 100644 index 000000000..10758738c --- /dev/null +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -0,0 +1,38 @@ + InstanceSettings::query()->firstOrCreate(['id' => 0])); +}); + +it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server double dispatch', function () { + $schedule = app(Schedule::class); + + $event = collect($schedule->events())->first( + fn ($e) => str_contains((string) $e->description, 'RegenerateSslCertJob') + ); + + expect($event)->not->toBeNull(); + expect($event->onOneServer)->toBeTrue(); +}); + +it('schedules every production job with onOneServer', function () { + $schedule = app(Schedule::class); + + $jobEvents = collect($schedule->events())->filter( + fn ($e) => str_contains((string) $e->description, 'App\\Jobs\\') + ); + + expect($jobEvents)->not->toBeEmpty(); + + $jobEvents->each(function ($event) { + expect($event->onOneServer)->toBeTrue( + "Scheduled job [{$event->description}] is missing ->onOneServer()" + ); + }); +}); From 9b977b9e4d12348c0172c9f73e3a9ab3bd430b0c Mon Sep 17 00:00:00 2001 From: michalzard Date: Thu, 21 May 2026 19:59:14 +0200 Subject: [PATCH 039/122] chore(gitea-runner): bumped version to 1.0.5 --- templates/compose/gitea-runner.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml index 42bb21984..712424881 100644 --- a/templates/compose/gitea-runner.yaml +++ b/templates/compose/gitea-runner.yaml @@ -6,7 +6,7 @@ services: runner: - image: 'docker.io/gitea/runner:1.0.4' + image: 'docker.io/gitea/runner:1.0.5' environment: - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}' - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}' From d415f3a3d1e60243281676d90bc7e8de8976f0e2 Mon Sep 17 00:00:00 2001 From: Firsak <31401457+Firsak@users.noreply.github.com> Date: Fri, 22 May 2026 11:06:32 +0200 Subject: [PATCH 040/122] fix(team): prevent 500 after deleting the current team When a user deletes their current team, the session and cache still reference the just-deleted team. `refreshSession()` then resolves that stale team via `currentTeam()`, calls `Team::find()` (which returns null because the row is gone) and dereferences `$team->id`, leaving the session without a current team. The subsequent redirect to the team page assigns the now-null `currentTeam()` to the non-nullable `Team $team` property in `Team\Index::mount()`, throwing a TypeError and producing an HTTP 500. Guard `refreshSession()` against a deleted current team: fall back to any team the user still belongs to, and if none remain, clear the stale session reference instead of dereferencing null. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap/helpers/shared.php | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 860b550dd..011c86744 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -353,14 +353,30 @@ function showBoarding(): bool function refreshSession(?Team $team = null): void { if (! $team) { - if (Auth::user()->currentTeam()) { - $team = Team::find(Auth::user()->currentTeam()->id); - } else { - $team = User::find(Auth::id())->teams->first(); + $currentTeam = Auth::user()->currentTeam(); + if ($currentTeam) { + // currentTeam() can resolve a stale (just-deleted) team from the + // session/cache, so Team::find() may still return null here. + $team = Team::find($currentTeam->id); + } + if (! $team) { + // Fall back to any team the user still belongs to. + $team = User::find(Auth::id())->teams()->first(); } } + // Clear old cache key format for backwards compatibility Cache::forget('team:'.Auth::id()); + + if (! $team) { + // The user has no team left (e.g. just deleted their current team and + // belongs to no other): clear the stale session reference instead of + // dereferencing null. + session()->forget('currentTeam'); + + return; + } + // Use new cache key format that includes team ID Cache::forget('user:'.Auth::id().':team:'.$team->id); Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) { From 5dda39e588f9bd96c08a6dea7ad63e1cd270b0c4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 12:30:00 +0200 Subject: [PATCH 041/122] fix(source): scope private key and source selection to current team The Source component now resolves the supplied private key and Git source IDs through team-scoped queries before persisting them, so a selection can only ever reference a resource owned by the current team. The source type is additionally restricted to the supported GitHub/GitLab app classes. The privateKeyId property is marked #[Locked] so it can only change through the dedicated handler rather than a direct property update. Adds feature tests covering team-scoped selection of private keys and Git sources. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Livewire/Project/Application/Source.php | 12 +- .../ApplicationSourceCrossTeamTest.php | 127 ++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ApplicationSourceCrossTeamTest.php diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 3ef5ccf7c..f14689ee0 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\PrivateKey; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -21,7 +23,7 @@ class Source extends Component #[Validate(['nullable', 'string'])] public ?string $privateKeyName = null; - #[Validate(['nullable', 'integer'])] + #[Locked] public ?int $privateKeyId = null; #[Validate(['required', 'string'])] @@ -103,7 +105,8 @@ public function setPrivateKey(int $privateKeyId) { try { $this->authorize('update', $this->application); - $this->privateKeyId = $privateKeyId; + $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId); + $this->privateKeyId = $key->id; $this->syncData(true); $this->getPrivateKeys(); $this->application->refresh(); @@ -136,8 +139,11 @@ public function changeSource($sourceId, $sourceType) try { $this->authorize('update', $this->application); + $allowedSourceTypes = [GithubApp::class, GitlabApp::class]; + abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404); + $source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId); $this->application->update([ - 'source_id' => $sourceId, + 'source_id' => $source->id, 'source_type' => $sourceType, ]); diff --git a/tests/Feature/ApplicationSourceCrossTeamTest.php b/tests/Feature/ApplicationSourceCrossTeamTest.php new file mode 100644 index 000000000..fc6f920e3 --- /dev/null +++ b/tests/Feature/ApplicationSourceCrossTeamTest.php @@ -0,0 +1,127 @@ + $name, + 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n{$material}\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => $fingerprint, + 'team_id' => $teamId, + ]); + $key->uuid = (string) new Cuid2; + $key->save(); + + return $key; + }); +} + +beforeEach(function () { + // handleError() turns a ModelNotFoundException into abort(404); rendering the 404 + // page reads InstanceSettings::get(), which findOrFail(0)s. Seed the singleton row. + // `id` is not in $fillable, so it must be set outside of mass assignment. + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + + // Team A — the attacker + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->teamA->members()->attach($this->userA->id, ['role' => 'owner']); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->applicationA = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'private_key_id' => null, + 'source_id' => null, + 'source_type' => null, + ]); + + // Team B — the victim (holds the secrets we are trying to steal) + $this->teamB = Team::factory()->create(); + + $this->victimPrivateKey = makePrivateKey('victim-ssh-key', 'VICTIM_KEY_MATERIAL', 'victim-fingerprint', $this->teamB->id); + + $this->victimGithubApp = GithubApp::create([ + 'name' => 'victim-github-app', + 'team_id' => $this->teamB->id, + 'private_key_id' => $this->victimPrivateKey->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('setPrivateKey rejects a PrivateKey owned by another team (GHSA-xrvp-4pp4-8rrw)', function () { + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('setPrivateKey', $this->victimPrivateKey->id); + + $this->applicationA->refresh(); + expect($this->applicationA->private_key_id)->not->toBe($this->victimPrivateKey->id); + expect($this->applicationA->private_key_id)->toBeNull(); +}); + +test('setPrivateKey accepts a PrivateKey owned by the current team', function () { + $ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('setPrivateKey', $ownKey->id); + + $this->applicationA->refresh(); + expect($this->applicationA->private_key_id)->toBe($ownKey->id); +}); + +test('changeSource rejects a GithubApp owned by another team (GHSA-xrvp-4pp4-8rrw)', function () { + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('changeSource', $this->victimGithubApp->id, GithubApp::class); + + $this->applicationA->refresh(); + expect($this->applicationA->source_id)->not->toBe($this->victimGithubApp->id); + expect($this->applicationA->source_type)->not->toBe(GithubApp::class); +}); + +test('changeSource rejects an arbitrary class as source_type', function () { + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('changeSource', $this->victimGithubApp->id, Server::class); + + $this->applicationA->refresh(); + expect($this->applicationA->source_type)->not->toBe(Server::class); +}); + +test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () { + // Without #[Locked], an attacker could POST {"updates": {"privateKeyId": }, + // "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through + // Application::update(['private_key_id' => $this->privateKeyId]) — bypassing setPrivateKey() + // and its team-scoped lookup entirely. Locking the property closes that path at the wire layer. + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->set('privateKeyId', $this->victimPrivateKey->id); +})->throws(CannotUpdateLockedPropertyException::class); From df166ac689194872a297e88e2036aa2628a74aab Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 12:37:48 +0200 Subject: [PATCH 042/122] fix(environment): scope DeleteEnvironment lookups to current team Scope DeleteEnvironment::mount() and delete() lookups through Environment::ownedByCurrentTeam() so an environment_id that belongs to another team resolves to a 404 instead of loading the foreign record. Mark $environment_id as #[Locked] so the public Livewire property can no longer be reassigned from the client. Add tests/Feature/DeleteEnvironmentTeamScopingTest.php covering mount, delete, the #[Locked] guard, and the team-scoped helper for both the cross-team and own-team cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Livewire/Project/DeleteEnvironment.php | 12 ++- .../DeleteEnvironmentTeamScopingTest.php | 88 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/DeleteEnvironmentTeamScopingTest.php diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index aa6e95975..4d28c7676 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -4,12 +4,14 @@ use App\Models\Environment; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class DeleteEnvironment extends Component { use AuthorizesRequests; + #[Locked] public int $environment_id; public bool $disabled = false; @@ -20,12 +22,8 @@ class DeleteEnvironment extends Component public function mount() { - try { - $this->environmentName = Environment::findOrFail($this->environment_id)->name; - $this->parameters = get_route_parameters(); - } catch (\Exception $e) { - return handleError($e, $this); - } + $this->parameters = get_route_parameters(); + $this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name; } public function delete() @@ -33,7 +31,7 @@ public function delete() $this->validate([ 'environment_id' => 'required|int', ]); - $environment = Environment::findOrFail($this->environment_id); + $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id); $this->authorize('delete', $environment); if ($environment->isEmpty()) { diff --git a/tests/Feature/DeleteEnvironmentTeamScopingTest.php b/tests/Feature/DeleteEnvironmentTeamScopingTest.php new file mode 100644 index 000000000..0730c0984 --- /dev/null +++ b/tests/Feature/DeleteEnvironmentTeamScopingTest.php @@ -0,0 +1,88 @@ + InstanceSettings::query()->create(['id' => 0])); + + // Current team + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Another team + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('mount cannot load DeleteEnvironment with environment from another team', function () { + Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentB->id]); +})->throws(ModelNotFoundException::class); + +test('mount can load DeleteEnvironment with own team environment', function () { + $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]); + + expect($component->get('environmentName'))->toBe($this->environmentA->name); +}); + +test('environment_id is locked and cannot be reassigned from the client', function () { + $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]); + + try { + $component->set('environment_id', $this->environmentB->id); + $this->fail('Setting a #[Locked] property should have thrown.'); + } catch (CannotUpdateLockedPropertyException) { + expect(true)->toBeTrue(); + } +}); + +test('delete still removes an empty environment owned by the current team', function () { + $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]) + ->set('parameters', ['project_uuid' => $this->projectA->uuid]); + + $component->call('delete'); + + expect(Environment::find($this->environmentA->id))->toBeNull(); +}); + +test('delete cannot resolve a non-empty environment from another team', function () { + // The team-scoped lookup must stay in the delete() path so the + // "has defined resources" branch can never run for an environment + // outside the caller's team. + Application::factory()->create([ + 'environment_id' => $this->environmentB->id, + ]); + + $teamScopedLookup = fn () => Environment::ownedByCurrentTeam() + ->findOrFail($this->environmentB->id); + + expect($teamScopedLookup)->toThrow(ModelNotFoundException::class); +}); + +test('team scoped lookup permits own team environment', function () { + // Positive case so the cross-team check above cannot pass merely + // because the helper itself is broken. + $found = Environment::ownedByCurrentTeam()->findOrFail($this->environmentA->id); + + expect($found->id)->toBe($this->environmentA->id); +}); From 5e0e6772d5aa8f355bd71cafccb2cfffa81bbe45 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 12:05:11 +0200 Subject: [PATCH 043/122] fix(deployments): load realtime assets without Vite Remove unused Vue, Echo, Pusher, and ioredis npm dependencies from the frontend build. Update realtime scripts and deployment log markup to work without bundling those assets through Vite. --- package-lock.json | 479 +----------------- package.json | 7 +- public/js/echo.js | 4 +- public/js/pusher.js | 7 +- .../application/deployment/show.blade.php | 88 ++-- vite.config.js | 14 - 6 files changed, 65 insertions(+), 534 deletions(-) diff --git a/package-lock.json b/package-lock.json index dcca9c394..ae5b214e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,15 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1", "playwright": "^1.58.2" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", - "@vitejs/plugin-vue": "6.0.3", - "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", "postcss": "8.5.6", - "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.2", - "vue": "3.5.26" + "vite": "7.3.2" } }, "node_modules/@alloc/quick-lru": { @@ -39,56 +34,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -531,12 +476,6 @@ "node": ">=18" } }, - "node_modules/@ioredis/commands": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", - "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", - "license": "MIT" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -587,13 +526,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -944,14 +876,6 @@ "win32" ] }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -1324,132 +1248,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", - "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.53" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", - "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.26", - "entities": "^7.0.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", - "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", - "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.26", - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", - "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", - "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", - "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", - "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/runtime-core": "3.5.26", - "@vue/shared": "3.5.26", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", - "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "vue": "3.5.26" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", - "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", - "dev": true, - "license": "MIT" - }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", @@ -1475,15 +1273,6 @@ "node": ">=6" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1496,39 +1285,6 @@ "node": ">=4" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1539,32 +1295,6 @@ "node": ">=8" } }, - "node_modules/engine.io-client": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", - "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3", - "xmlhttprequest-ssl": "~2.1.1" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -1579,19 +1309,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1634,13 +1351,6 @@ "@esbuild/win32-x64": "0.27.2" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1681,30 +1391,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ioredis": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", - "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "^1.1.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1715,20 +1401,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/laravel-echo": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.7.tgz", - "integrity": "sha512-MgD3ZFXqH5OOVdRjxNHPyQ0ijRr5+nLr7MtyF2XP+kRfhl+Qaa7qVzbtCn1HMgXuTn4SWH6ivn4qWVLlvRl8kg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "pusher-js": "*", - "socket.io-client": "*" - } - }, "node_modules/laravel-vite-plugin": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", @@ -2016,18 +1688,6 @@ "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -2059,12 +1719,6 @@ "mini-svg-data-uri": "cli.js" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2204,16 +1858,6 @@ "react": ">=16.0.0" } }, - "node_modules/pusher-js": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz", - "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tweetnacl": "^1.0.3" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -2225,27 +1869,6 @@ "node": ">=0.10.0" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2291,38 +1914,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/socket.io-client": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", - "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2333,12 +1924,6 @@ "node": ">=0.10.0" } }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/tailwind-scrollbar": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz", @@ -2392,13 +1977,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", - "dev": true, - "license": "Unlicense" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2503,61 +2081,6 @@ "funding": { "url": "https://github.com/sponsors/jonschlinkert" } - }, - "node_modules/vue": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", - "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-sfc": "3.5.26", - "@vue/runtime-dom": "3.5.26", - "@vue/server-renderer": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", - "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.0" - } } } } diff --git a/package.json b/package.json index 6b0b58522..eb199e5ea 100644 --- a/package.json +++ b/package.json @@ -8,22 +8,17 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", - "@vitejs/plugin-vue": "6.0.3", - "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", "postcss": "8.5.6", - "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.2", - "vue": "3.5.26" + "vite": "7.3.2" }, "dependencies": { "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1", "playwright": "^1.58.2" } } diff --git a/public/js/echo.js b/public/js/echo.js index 971662063..22f280301 100644 --- a/public/js/echo.js +++ b/public/js/echo.js @@ -1,2 +1,2 @@ -// Source: https://cdnjs.cloudflare.com/ajax/libs/laravel-echo/1.15.3/echo.iife.min.js -var Echo=function(){"use strict";function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){for(var n=0;n{var s;e.startsWith("pusher:")||(s=String(this.options.namespace??"").replace(/\./g,"\\"),s=e.startsWith(s)?e.substring(s.length+1):"."+e,n(s,t))}),this}stopListening(e,t){return t?this.subscription.unbind(this.eventFormatter.format(e),t):this.subscription.unbind(this.eventFormatter.format(e)),this}stopListeningToAll(e){return e?this.subscription.unbind_global(e):this.subscription.unbind_global(),this}subscribed(e){return this.on("pusher:subscription_succeeded",()=>{e()}),this}error(t){return this.on("pusher:subscription_error",e=>{t(e)}),this}on(e,t){return this.subscription.bind(e,t),this}}class i extends s{whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}}class r extends s{whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}}class o extends i{here(e){return this.on("pusher:subscription_succeeded",t=>{e(Object.keys(t.members).map(e=>t.members[e]))}),this}joining(t){return this.on("pusher:member_added",e=>{t(e.info)}),this}whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}leaving(t){return this.on("pusher:member_removed",e=>{t(e.info)}),this}}class h extends t{constructor(e,t,s){super(),this.events={},this.listeners={},this.name=t,this.socket=e,this.options=s,this.eventFormatter=new n(this.options.namespace),this.subscribe()}subscribe(){this.socket.emit("subscribe",{channel:this.name,auth:this.options.auth||{}})}unsubscribe(){this.unbind(),this.socket.emit("unsubscribe",{channel:this.name,auth:this.options.auth||{}})}listen(e,t){return this.on(this.eventFormatter.format(e),t),this}stopListening(e,t){return this.unbindEvent(this.eventFormatter.format(e),t),this}subscribed(t){return this.on("connect",e=>{t(e)}),this}error(e){return this}on(s,e){return this.listeners[s]=this.listeners[s]||[],this.events[s]||(this.events[s]=(e,t)=>{this.name===e&&this.listeners[s]&&this.listeners[s].forEach(e=>e(t))},this.socket.on(s,this.events[s])),this.listeners[s].push(e),this}unbind(){Object.keys(this.events).forEach(e=>{this.unbindEvent(e)})}unbindEvent(e,t){this.listeners[e]=this.listeners[e]||[],t&&(this.listeners[e]=this.listeners[e].filter(e=>e!==t)),t&&0!==this.listeners[e].length||(this.events[e]&&(this.socket.removeListener(e,this.events[e]),delete this.events[e]),delete this.listeners[e])}}class c extends h{whisper(e,t){return this.socket.emit("client event",{channel:this.name,event:"client-"+e,data:t}),this}}class a extends c{here(t){return this.on("presence:subscribed",e=>{t(e.map(e=>e.user_info))}),this}joining(t){return this.on("presence:joining",e=>t(e.user_info)),this}whisper(e,t){return this.socket.emit("client event",{channel:this.name,event:"client-"+e,data:t}),this}leaving(t){return this.on("presence:leaving",e=>t(e.user_info)),this}}class u extends t{subscribe(){}unsubscribe(){}listen(e,t){return this}listenToAll(e){return this}stopListening(e,t){return this}subscribed(e){return this}error(e){return this}on(e,t){return this}}class l extends u{whisper(e,t){return this}}class p extends u{whisper(e,t){return this}}class d extends l{here(e){return this}joining(e){return this}whisper(e,t){return this}leaving(e){return this}}const b=class b{constructor(e){this.setOptions(e),this.connect()}setOptions(e){this.options={...b._defaultOptions,...e,broadcaster:e.broadcaster};let t=this.csrfToken();t&&(this.options.auth.headers["X-CSRF-TOKEN"]=t,this.options.userAuthentication.headers["X-CSRF-TOKEN"]=t),(t=this.options.bearerToken)&&(this.options.auth.headers.Authorization="Bearer "+t,this.options.userAuthentication.headers.Authorization="Bearer "+t)}csrfToken(){var e;return typeof window<"u"&&null!=(e=window.Laravel)&&e.csrfToken?window.Laravel.csrfToken:this.options.csrfToken||(typeof document<"u"&&"function"==typeof document.querySelector?(null==(e=document.querySelector('meta[name="csrf-token"]'))?void 0:e.getAttribute("content"))??null:null)}};b._defaultOptions={auth:{headers:{}},authEndpoint:"/broadcasting/auth",userAuthentication:{endpoint:"/broadcasting/user-auth",headers:{}},csrfToken:null,bearerToken:null,host:null,key:null,namespace:"App.Events"};var v=b;class f extends v{constructor(){super(...arguments),this.channels={}}connect(){if(typeof this.options.client<"u")this.pusher=this.options.client;else if(this.options.Pusher)this.pusher=new this.options.Pusher(this.options.key,this.options);else{if(!(typeof window<"u"&&typeof window.Pusher<"u"))throw new Error("Pusher client not found. Should be globally available or passed via options.client");this.pusher=new window.Pusher(this.options.key,this.options)}}signin(){this.pusher.signin()}listen(e,t,s){return this.channel(e).listen(t,s)}channel(e){return this.channels[e]||(this.channels[e]=new s(this.pusher,e,this.options)),this.channels[e]}privateChannel(e){return this.channels["private-"+e]||(this.channels["private-"+e]=new i(this.pusher,"private-"+e,this.options)),this.channels["private-"+e]}encryptedPrivateChannel(e){return this.channels["private-encrypted-"+e]||(this.channels["private-encrypted-"+e]=new r(this.pusher,"private-encrypted-"+e,this.options)),this.channels["private-encrypted-"+e]}presenceChannel(e){return this.channels["presence-"+e]||(this.channels["presence-"+e]=new o(this.pusher,"presence-"+e,this.options)),this.channels["presence-"+e]}leave(e){[e,"private-"+e,"private-encrypted-"+e,"presence-"+e].forEach(e=>{this.leaveChannel(e)})}leaveChannel(e){this.channels[e]&&(this.channels[e].unsubscribe(),delete this.channels[e])}socketId(){return this.pusher.connection.socket_id}disconnect(){this.pusher.disconnect()}}class m extends v{constructor(){super(...arguments),this.channels={}}connect(){let e=this.getSocketIO();this.socket=e(this.options.host??void 0,this.options),this.socket.io.on("reconnect",()=>{Object.values(this.channels).forEach(e=>{e.subscribe()})})}getSocketIO(){if(typeof this.options.client<"u")return this.options.client;if(typeof window<"u"&&typeof window.io<"u")return window.io;throw new Error("Socket.io client not found. Should be globally available or passed via options.client")}listen(e,t,s){return this.channel(e).listen(t,s)}channel(e){return this.channels[e]||(this.channels[e]=new h(this.socket,e,this.options)),this.channels[e]}privateChannel(e){return this.channels["private-"+e]||(this.channels["private-"+e]=new c(this.socket,"private-"+e,this.options)),this.channels["private-"+e]}presenceChannel(e){return this.channels["presence-"+e]||(this.channels["presence-"+e]=new a(this.socket,"presence-"+e,this.options)),this.channels["presence-"+e]}leave(e){[e,"private-"+e,"presence-"+e].forEach(e=>{this.leaveChannel(e)})}leaveChannel(e){this.channels[e]&&(this.channels[e].unsubscribe(),delete this.channels[e])}socketId(){return this.socket.id}disconnect(){this.socket.disconnect()}}class w extends v{constructor(){super(...arguments),this.channels={}}connect(){}listen(e,t,s){return new u}channel(e){return new u}privateChannel(e){return new l}encryptedPrivateChannel(e){return new p}presenceChannel(e){return new d}leave(e){}leaveChannel(e){}socketId(){return"fake-socket-id"}disconnect(){}}return e.Channel=t,e.Connector=v,e.EventFormatter=n,e.default=class{constructor(e){this.options=e,this.connect(),this.options.withoutInterceptors||this.registerInterceptors()}channel(e){return this.connector.channel(e)}connect(){if("reverb"===this.options.broadcaster)this.connector=new f({...this.options,cluster:""});else if("pusher"===this.options.broadcaster)this.connector=new f(this.options);else if("ably"===this.options.broadcaster)this.connector=new f({...this.options,cluster:"",broadcaster:"pusher"});else if("socket.io"===this.options.broadcaster)this.connector=new m(this.options);else if("null"===this.options.broadcaster)this.connector=new w(this.options);else{if("function"!=typeof this.options.broadcaster||!function(e){try{new e}catch(e){if(e instanceof Error&&e.message.includes("is not a constructor"))return}return 1}(this.options.broadcaster))throw new Error(`Broadcaster ${typeof this.options.broadcaster} ${String(this.options.broadcaster)} is not supported.`);this.connector=new this.options.broadcaster(this.options)}}disconnect(){this.connector.disconnect()}join(e){return this.connector.presenceChannel(e)}leave(e){this.connector.leave(e)}leaveChannel(e){this.connector.leaveChannel(e)}leaveAllChannels(){for(const e in this.connector.channels)this.leaveChannel(e)}listen(e,t,s){return this.connector.listen(e,t,s)}private(e){return this.connector.privateChannel(e)}encryptedPrivate(e){if(this.connectorSupportsEncryptedPrivateChannels(this.connector))return this.connector.encryptedPrivateChannel(e);throw new Error(`Broadcaster ${typeof this.options.broadcaster} ${String(this.options.broadcaster)} does not support encrypted private channels.`)}connectorSupportsEncryptedPrivateChannels(e){return e instanceof f||e instanceof w}socketId(){return this.connector.socketId()}registerInterceptors(){typeof Vue<"u"&&null!=Vue&&Vue.http&&this.registerVueRequestInterceptor(),"function"==typeof axios&&this.registerAxiosRequestInterceptor(),"function"==typeof jQuery&&this.registerjQueryAjaxSetup(),"object"==typeof Turbo&&this.registerTurboRequestInterceptor()}registerVueRequestInterceptor(){Vue.http.interceptors.push((e,t)=>{this.socketId()&&e.headers.set("X-Socket-ID",this.socketId()),t()})}registerAxiosRequestInterceptor(){axios.interceptors.request.use(e=>(this.socketId()&&(e.headers["X-Socket-Id"]=this.socketId()),e))}registerjQueryAjaxSetup(){typeof jQuery.ajax<"u"&&jQuery.ajaxPrefilter((e,t,s)=>{this.socketId()&&s.setRequestHeader("X-Socket-Id",this.socketId())})}registerTurboRequestInterceptor(){document.addEventListener("turbo:before-fetch-request",e=>{e.detail.fetchOptions.headers["X-Socket-Id"]=this.socketId()})}},Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}}),e}({}); diff --git a/public/js/pusher.js b/public/js/pusher.js index f18c77a4c..862e89bc0 100644 --- a/public/js/pusher.js +++ b/public/js/pusher.js @@ -1,10 +1,9 @@ /*! - * Pusher JavaScript Library v8.3.0 + * Pusher JavaScript Library v8.4.0 * https://pusher.com/ - * + * https://cdnjs.cloudflare.com/ajax/libs/pusher/8.4.0/pusher.min.js * Copyright 2020, Pusher * Released under the MIT licence. */ -// Source: https://cdnjs.cloudflare.com/ajax/libs/pusher/8.3.0/pusher.min.js -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pusher=e():t.Pusher=e()}(window,(function(){return function(t){var e={};function n(i){if(e[i])return e[i].exports;var r=e[i]={i:i,l:!1,exports:{}};return t[i].call(r.exports,r,r.exports,n),r.l=!0,r.exports}return n.m=t,n.c=e,n.d=function(t,e,i){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:i})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)n.d(i,r,function(e){return t[e]}.bind(null,r));return i},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=2)}([function(t,e,n){"use strict";var i,r=this&&this.__extends||(i=function(t,e){return(i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,e)},function(t,e){function n(){this.constructor=t}i(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0});var s=function(){function t(t){void 0===t&&(t="="),this._paddingCharacter=t}return t.prototype.encodedLength=function(t){return this._paddingCharacter?(t+2)/3*4|0:(8*t+5)/6|0},t.prototype.encode=function(t){for(var e="",n=0;n>>18&63),e+=this._encodeByte(i>>>12&63),e+=this._encodeByte(i>>>6&63),e+=this._encodeByte(i>>>0&63)}var r=t.length-n;if(r>0){i=t[n]<<16|(2===r?t[n+1]<<8:0);e+=this._encodeByte(i>>>18&63),e+=this._encodeByte(i>>>12&63),e+=2===r?this._encodeByte(i>>>6&63):this._paddingCharacter||"",e+=this._paddingCharacter||""}return e},t.prototype.maxDecodedLength=function(t){return this._paddingCharacter?t/4*3|0:(6*t+7)/8|0},t.prototype.decodedLength=function(t){return this.maxDecodedLength(t.length-this._getPaddingLength(t))},t.prototype.decode=function(t){if(0===t.length)return new Uint8Array(0);for(var e=this._getPaddingLength(t),n=t.length-e,i=new Uint8Array(this.maxDecodedLength(n)),r=0,s=0,o=0,a=0,c=0,h=0,u=0;s>>4,i[r++]=c<<4|h>>>2,i[r++]=h<<6|u,o|=256&a,o|=256&c,o|=256&h,o|=256&u;if(s>>4,o|=256&a,o|=256&c),s>>2,o|=256&h),s>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-15,e+=62-t>>>8&3,String.fromCharCode(e)},t.prototype._decodeChar=function(t){var e=256;return e+=(42-t&t-44)>>>8&-256+t-43+62,e+=(46-t&t-48)>>>8&-256+t-47+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},t.prototype._getPaddingLength=function(t){var e=0;if(this._paddingCharacter){for(var n=t.length-1;n>=0&&t[n]===this._paddingCharacter;n--)e++;if(t.length<4||e>2)throw new Error("Base64Coder: incorrect padding")}return e},t}();e.Coder=s;var o=new s;e.encode=function(t){return o.encode(t)},e.decode=function(t){return o.decode(t)};var a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return r(e,t),e.prototype._encodeByte=function(t){var e=t;return e+=65,e+=25-t>>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-13,e+=62-t>>>8&49,String.fromCharCode(e)},e.prototype._decodeChar=function(t){var e=256;return e+=(44-t&t-46)>>>8&-256+t-45+62,e+=(94-t&t-96)>>>8&-256+t-95+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},e}(s);e.URLSafeCoder=a;var c=new a;e.encodeURLSafe=function(t){return c.encode(t)},e.decodeURLSafe=function(t){return c.decode(t)},e.encodedLength=function(t){return o.encodedLength(t)},e.maxDecodedLength=function(t){return o.maxDecodedLength(t)},e.decodedLength=function(t){return o.decodedLength(t)}},function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var i="utf8: invalid source encoding";function r(t){for(var e=0,n=0;n=t.length-1)throw new Error("utf8: invalid string");n++,e+=4}}return e}e.encode=function(t){for(var e=new Uint8Array(r(t)),n=0,i=0;i>6,e[n++]=128|63&s):s<55296?(e[n++]=224|s>>12,e[n++]=128|s>>6&63,e[n++]=128|63&s):(i++,s=(1023&s)<<10,s|=1023&t.charCodeAt(i),s+=65536,e[n++]=240|s>>18,e[n++]=128|s>>12&63,e[n++]=128|s>>6&63,e[n++]=128|63&s)}return e},e.encodedLength=r,e.decode=function(t){for(var e=[],n=0;n=t.length)throw new Error(i);if(128!=(192&(o=t[++n])))throw new Error(i);r=(31&r)<<6|63&o,s=128}else if(r<240){if(n>=t.length-1)throw new Error(i);var o=t[++n],a=t[++n];if(128!=(192&o)||128!=(192&a))throw new Error(i);r=(15&r)<<12|(63&o)<<6|63&a,s=2048}else{if(!(r<248))throw new Error(i);if(n>=t.length-2)throw new Error(i);o=t[++n],a=t[++n];var c=t[++n];if(128!=(192&o)||128!=(192&a)||128!=(192&c))throw new Error(i);r=(15&r)<<18|(63&o)<<12|(63&a)<<6|63&c,s=65536}if(r=55296&&r<=57343)throw new Error(i);if(r>=65536){if(r>1114111)throw new Error(i);r-=65536,e.push(String.fromCharCode(55296|r>>10)),r=56320|1023&r}}e.push(String.fromCharCode(r))}return e.join("")}},function(t,e,n){t.exports=n(3).default},function(t,e,n){"use strict";n.r(e);class i{constructor(t,e){this.lastId=0,this.prefix=t,this.name=e}create(t){this.lastId++;var e=this.lastId,n=this.prefix+e,i=this.name+"["+e+"]",r=!1,s=function(){r||(t.apply(null,arguments),r=!0)};return this[e]=s,{number:e,id:n,name:i,callback:s}}remove(t){delete this[t.number]}}var r=new i("_pusher_script_","Pusher.ScriptReceivers"),s={VERSION:"8.3.0",PROTOCOL:7,wsPort:80,wssPort:443,wsPath:"",httpHost:"sockjs.pusher.com",httpPort:80,httpsPort:443,httpPath:"/pusher",stats_host:"stats.pusher.com",authEndpoint:"/pusher/auth",authTransport:"ajax",activityTimeout:12e4,pongTimeout:3e4,unavailableTimeout:1e4,userAuthentication:{endpoint:"/pusher/user-auth",transport:"ajax"},channelAuthorization:{endpoint:"/pusher/auth",transport:"ajax"},cdn_http:"http://js.pusher.com",cdn_https:"https://js.pusher.com",dependency_suffix:""};var o=new i("_pusher_dependencies","Pusher.DependenciesReceivers"),a=new class{constructor(t){this.options=t,this.receivers=t.receivers||r,this.loading={}}load(t,e,n){var i=this;if(i.loading[t]&&i.loading[t].length>0)i.loading[t].push(n);else{i.loading[t]=[n];var r=ue.createScriptRequest(i.getPath(t,e)),s=i.receivers.create((function(e){if(i.receivers.remove(s),i.loading[t]){var n=i.loading[t];delete i.loading[t];for(var o=function(t){t||r.cleanup()},a=0;a>>6)+S(128|63&e):S(224|e>>>12&15)+S(128|e>>>6&63)+S(128|63&e)},E=function(t){return t.replace(/[^\x00-\x7F]/g,P)},O=function(t){var e=[0,2,1][t.length%3],n=t.charCodeAt(0)<<16|(t.length>1?t.charCodeAt(1):0)<<8|(t.length>2?t.charCodeAt(2):0);return[_.charAt(n>>>18),_.charAt(n>>>12&63),e>=2?"=":_.charAt(n>>>6&63),e>=1?"=":_.charAt(63&n)].join("")},x=window.btoa||function(t){return t.replace(/[\s\S]{1,3}/g,O)};var L=class{constructor(t,e,n,i){this.clear=e,this.timer=t(()=>{this.timer&&(this.timer=i(this.timer))},n)}isRunning(){return null!==this.timer}ensureAborted(){this.timer&&(this.clear(this.timer),this.timer=null)}};function A(t){window.clearTimeout(t)}function R(t){window.clearInterval(t)}class I extends L{constructor(t,e){super(setTimeout,A,t,(function(t){return e(),null}))}}class D extends L{constructor(t,e){super(setInterval,R,t,(function(t){return e(),t}))}}var j={now:()=>Date.now?Date.now():(new Date).valueOf(),defer:t=>new I(0,t),method(t,...e){var n=Array.prototype.slice.call(arguments,1);return function(e){return e[t].apply(e,n.concat(arguments))}}};function N(t,...e){for(var n=0;n{window.console&&window.console.log&&window.console.log(t)}}debug(...t){this.log(this.globalLog,t)}warn(...t){this.log(this.globalLogWarn,t)}error(...t){this.log(this.globalLogError,t)}globalLogWarn(t){window.console&&window.console.warn?window.console.warn(t):this.globalLog(t)}globalLogError(t){window.console&&window.console.error?window.console.error(t):this.globalLogWarn(t)}log(t,...e){var n=H.apply(this,arguments);if(Le.log)Le.log(n);else if(Le.logToConsole){t.bind(this)(n)}}},Y=function(t,e,n,i,r){void 0===n.headers&&null==n.headersProvider||V.warn(`To send headers with the ${i.toString()} request, you must use AJAX, rather than JSONP.`);var s=t.nextAuthCallbackID.toString();t.nextAuthCallbackID++;var o=t.getDocument(),a=o.createElement("script");t.auth_callbacks[s]=function(t){r(null,t)};var c="Pusher.auth_callbacks['"+s+"']";a.src=n.endpoint+"?callback="+encodeURIComponent(c)+"&"+e;var h=o.getElementsByTagName("head")[0]||o.documentElement;h.insertBefore(a,h.firstChild)};class Q{constructor(t){this.src=t}send(t){var e=this,n="Error loading "+e.src;e.script=document.createElement("script"),e.script.id=t.id,e.script.src=e.src,e.script.type="text/javascript",e.script.charset="UTF-8",e.script.addEventListener?(e.script.onerror=function(){t.callback(n)},e.script.onload=function(){t.callback(null)}):e.script.onreadystatechange=function(){"loaded"!==e.script.readyState&&"complete"!==e.script.readyState||t.callback(null)},void 0===e.script.async&&document.attachEvent&&/opera/i.test(navigator.userAgent)?(e.errorScript=document.createElement("script"),e.errorScript.id=t.id+"_error",e.errorScript.text=t.name+"('"+n+"');",e.script.async=e.errorScript.async=!1):e.script.async=!0;var i=document.getElementsByTagName("head")[0];i.insertBefore(e.script,i.firstChild),e.errorScript&&i.insertBefore(e.errorScript,e.script.nextSibling)}cleanup(){this.script&&(this.script.onload=this.script.onerror=null,this.script.onreadystatechange=null),this.script&&this.script.parentNode&&this.script.parentNode.removeChild(this.script),this.errorScript&&this.errorScript.parentNode&&this.errorScript.parentNode.removeChild(this.errorScript),this.script=null,this.errorScript=null}}class K{constructor(t,e){this.url=t,this.data=e}send(t){if(!this.request){var e=W(this.data),n=this.url+"/"+t.number+"?"+e;this.request=ue.createScriptRequest(n),this.request.send(t)}}cleanup(){this.request&&this.request.cleanup()}}var Z={name:"jsonp",getAgent:function(t,e){return function(n,i){var s="http"+(e?"s":"")+"://"+(t.host||t.options.host)+t.options.path,o=ue.createJSONPRequest(s,n),a=ue.ScriptReceivers.create((function(e,n){r.remove(a),o.cleanup(),n&&n.host&&(t.host=n.host),i&&i(e,n)}));o.send(a)}}};function tt(t,e,n){return t+(e.useTLS?"s":"")+"://"+(e.useTLS?e.hostTLS:e.hostNonTLS)+n}function et(t,e){return"/app/"+t+("?protocol="+s.PROTOCOL+"&client=js&version="+s.VERSION+(e?"&"+e:""))}var nt={getInitial:function(t,e){return tt("ws",e,(e.httpPath||"")+et(t,"flash=false"))}},it={getInitial:function(t,e){return tt("http",e,(e.httpPath||"/pusher")+et(t))}},rt={getInitial:function(t,e){return tt("http",e,e.httpPath||"/pusher")},getPath:function(t,e){return et(t)}};class st{constructor(){this._callbacks={}}get(t){return this._callbacks[ot(t)]}add(t,e,n){var i=ot(t);this._callbacks[i]=this._callbacks[i]||[],this._callbacks[i].push({fn:e,context:n})}remove(t,e,n){if(t||e||n){var i=t?[ot(t)]:z(this._callbacks);e||n?this.removeCallback(i,e,n):this.removeAllCallbacks(i)}else this._callbacks={}}removeCallback(t,e,n){q(t,(function(t){this._callbacks[t]=F(this._callbacks[t]||[],(function(t){return e&&e!==t.fn||n&&n!==t.context})),0===this._callbacks[t].length&&delete this._callbacks[t]}),this)}removeAllCallbacks(t){q(t,(function(t){delete this._callbacks[t]}),this)}}function ot(t){return"_"+t}class at{constructor(t){this.callbacks=new st,this.global_callbacks=[],this.failThrough=t}bind(t,e,n){return this.callbacks.add(t,e,n),this}bind_global(t){return this.global_callbacks.push(t),this}unbind(t,e,n){return this.callbacks.remove(t,e,n),this}unbind_global(t){return t?(this.global_callbacks=F(this.global_callbacks||[],e=>e!==t),this):(this.global_callbacks=[],this)}unbind_all(){return this.unbind(),this.unbind_global(),this}emit(t,e,n){for(var i=0;i0)for(i=0;i{this.onError(t),this.changeState("closed")}),!1}return this.bindListeners(),V.debug("Connecting",{transport:this.name,url:t}),this.changeState("connecting"),!0}close(){return!!this.socket&&(this.socket.close(),!0)}send(t){return"open"===this.state&&(j.defer(()=>{this.socket&&this.socket.send(t)}),!0)}ping(){"open"===this.state&&this.supportsPing()&&this.socket.ping()}onOpen(){this.hooks.beforeOpen&&this.hooks.beforeOpen(this.socket,this.hooks.urls.getPath(this.key,this.options)),this.changeState("open"),this.socket.onopen=void 0}onError(t){this.emit("error",{type:"WebSocketError",error:t}),this.timeline.error(this.buildTimelineMessage({error:t.toString()}))}onClose(t){t?this.changeState("closed",{code:t.code,reason:t.reason,wasClean:t.wasClean}):this.changeState("closed"),this.unbindListeners(),this.socket=void 0}onMessage(t){this.emit("message",t)}onActivity(){this.emit("activity")}bindListeners(){this.socket.onopen=()=>{this.onOpen()},this.socket.onerror=t=>{this.onError(t)},this.socket.onclose=t=>{this.onClose(t)},this.socket.onmessage=t=>{this.onMessage(t)},this.supportsPing()&&(this.socket.onactivity=()=>{this.onActivity()})}unbindListeners(){this.socket&&(this.socket.onopen=void 0,this.socket.onerror=void 0,this.socket.onclose=void 0,this.socket.onmessage=void 0,this.supportsPing()&&(this.socket.onactivity=void 0))}changeState(t,e){this.state=t,this.timeline.info(this.buildTimelineMessage({state:t,params:e})),this.emit(t,e)}buildTimelineMessage(t){return N({cid:this.id},t)}}class ht{constructor(t){this.hooks=t}isSupported(t){return this.hooks.isSupported(t)}createConnection(t,e,n,i){return new ct(this.hooks,t,e,n,i)}}var ut=new ht({urls:nt,handlesActivityChecks:!1,supportsPing:!1,isInitialized:function(){return Boolean(ue.getWebSocketAPI())},isSupported:function(){return Boolean(ue.getWebSocketAPI())},getSocket:function(t){return ue.createWebSocket(t)}}),lt={urls:it,handlesActivityChecks:!1,supportsPing:!0,isInitialized:function(){return!0}},dt=N({getSocket:function(t){return ue.HTTPFactory.createStreamingSocket(t)}},lt),pt=N({getSocket:function(t){return ue.HTTPFactory.createPollingSocket(t)}},lt),ft={isSupported:function(){return ue.isXHRSupported()}},gt={ws:ut,xhr_streaming:new ht(N({},dt,ft)),xhr_polling:new ht(N({},pt,ft))},vt=new ht({file:"sockjs",urls:rt,handlesActivityChecks:!0,supportsPing:!1,isSupported:function(){return!0},isInitialized:function(){return void 0!==window.SockJS},getSocket:function(t,e){return new window.SockJS(t,null,{js_path:a.getPath("sockjs",{useTLS:e.useTLS}),ignore_null_origin:e.ignoreNullOrigin})},beforeOpen:function(t,e){t.send(JSON.stringify({path:e}))}}),mt={isSupported:function(t){return ue.isXDRSupported(t.useTLS)}},bt=new ht(N({},dt,mt)),yt=new ht(N({},pt,mt));gt.xdr_streaming=bt,gt.xdr_polling=yt,gt.sockjs=vt;var wt=gt;var St=new class extends at{constructor(){super();var t=this;void 0!==window.addEventListener&&(window.addEventListener("online",(function(){t.emit("online")}),!1),window.addEventListener("offline",(function(){t.emit("offline")}),!1))}isOnline(){return void 0===window.navigator.onLine||window.navigator.onLine}};class _t{constructor(t,e,n){this.manager=t,this.transport=e,this.minPingDelay=n.minPingDelay,this.maxPingDelay=n.maxPingDelay,this.pingDelay=void 0}createConnection(t,e,n,i){i=N({},i,{activityTimeout:this.pingDelay});var r=this.transport.createConnection(t,e,n,i),s=null,o=function(){r.unbind("open",o),r.bind("closed",a),s=j.now()},a=t=>{if(r.unbind("closed",a),1002===t.code||1003===t.code)this.manager.reportDeath();else if(!t.wasClean&&s){var e=j.now()-s;e<2*this.maxPingDelay&&(this.manager.reportDeath(),this.pingDelay=Math.max(e/2,this.minPingDelay))}};return r.bind("open",o),r}isSupported(t){return this.manager.isAlive()&&this.transport.isSupported(t)}}const kt={decodeMessage:function(t){try{var e=JSON.parse(t.data),n=e.data;if("string"==typeof n)try{n=JSON.parse(e.data)}catch(t){}var i={event:e.event,channel:e.channel,data:n};return e.user_id&&(i.user_id=e.user_id),i}catch(e){throw{type:"MessageParseError",error:e,data:t.data}}},encodeMessage:function(t){return JSON.stringify(t)},processHandshake:function(t){var e=kt.decodeMessage(t);if("pusher:connection_established"===e.event){if(!e.data.activity_timeout)throw"No activity timeout specified in handshake";return{action:"connected",id:e.data.socket_id,activityTimeout:1e3*e.data.activity_timeout}}if("pusher:error"===e.event)return{action:this.getCloseAction(e.data),error:this.getCloseError(e.data)};throw"Invalid handshake"},getCloseAction:function(t){return t.code<4e3?t.code>=1002&&t.code<=1004?"backoff":null:4e3===t.code?"tls_only":t.code<4100?"refused":t.code<4200?"backoff":t.code<4300?"retry":"refused"},getCloseError:function(t){return 1e3!==t.code&&1001!==t.code?{type:"PusherError",data:{code:t.code,message:t.reason||t.message}}:null}};var Ct=kt;class Tt extends at{constructor(t,e){super(),this.id=t,this.transport=e,this.activityTimeout=e.activityTimeout,this.bindListeners()}handlesActivityChecks(){return this.transport.handlesActivityChecks()}send(t){return this.transport.send(t)}send_event(t,e,n){var i={event:t,data:e};return n&&(i.channel=n),V.debug("Event sent",i),this.send(Ct.encodeMessage(i))}ping(){this.transport.supportsPing()?this.transport.ping():this.send_event("pusher:ping",{})}close(){this.transport.close()}bindListeners(){var t={message:t=>{var e;try{e=Ct.decodeMessage(t)}catch(e){this.emit("error",{type:"MessageParseError",error:e,data:t.data})}if(void 0!==e){switch(V.debug("Event recd",e),e.event){case"pusher:error":this.emit("error",{type:"PusherError",data:e.data});break;case"pusher:ping":this.emit("ping");break;case"pusher:pong":this.emit("pong")}this.emit("message",e)}},activity:()=>{this.emit("activity")},error:t=>{this.emit("error",t)},closed:t=>{e(),t&&t.code&&this.handleCloseEvent(t),this.transport=null,this.emit("closed")}},e=()=>{M(t,(t,e)=>{this.transport.unbind(e,t)})};M(t,(t,e)=>{this.transport.bind(e,t)})}handleCloseEvent(t){var e=Ct.getCloseAction(t),n=Ct.getCloseError(t);n&&this.emit("error",n),e&&this.emit(e,{action:e,error:n})}}class Pt{constructor(t,e){this.transport=t,this.callback=e,this.bindListeners()}close(){this.unbindListeners(),this.transport.close()}bindListeners(){this.onMessage=t=>{var e;this.unbindListeners();try{e=Ct.processHandshake(t)}catch(t){return this.finish("error",{error:t}),void this.transport.close()}"connected"===e.action?this.finish("connected",{connection:new Tt(e.id,this.transport),activityTimeout:e.activityTimeout}):(this.finish(e.action,{error:e.error}),this.transport.close())},this.onClosed=t=>{this.unbindListeners();var e=Ct.getCloseAction(t)||"backoff",n=Ct.getCloseError(t);this.finish(e,{error:n})},this.transport.bind("message",this.onMessage),this.transport.bind("closed",this.onClosed)}unbindListeners(){this.transport.unbind("message",this.onMessage),this.transport.unbind("closed",this.onClosed)}finish(t,e){this.callback(N({transport:this.transport,action:t},e))}}class Et{constructor(t,e){this.timeline=t,this.options=e||{}}send(t,e){this.timeline.isEmpty()||this.timeline.send(ue.TimelineTransport.getAgent(this,t),e)}}class Ot extends at{constructor(t,e){super((function(e,n){V.debug("No callbacks on "+t+" for "+e)})),this.name=t,this.pusher=e,this.subscribed=!1,this.subscriptionPending=!1,this.subscriptionCancelled=!1}authorize(t,e){return e(null,{auth:""})}trigger(t,e){if(0!==t.indexOf("client-"))throw new l("Event '"+t+"' does not start with 'client-'");if(!this.subscribed){var n=u("triggeringClientEvents");V.warn("Client event triggered before channel 'subscription_succeeded' event . "+n)}return this.pusher.send_event(t,e,this.name)}disconnect(){this.subscribed=!1,this.subscriptionPending=!1}handleEvent(t){var e=t.event,n=t.data;if("pusher_internal:subscription_succeeded"===e)this.handleSubscriptionSucceededEvent(t);else if("pusher_internal:subscription_count"===e)this.handleSubscriptionCountEvent(t);else if(0!==e.indexOf("pusher_internal:")){this.emit(e,n,{})}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):this.emit("pusher:subscription_succeeded",t.data)}handleSubscriptionCountEvent(t){t.data.subscription_count&&(this.subscriptionCount=t.data.subscription_count),this.emit("pusher:subscription_count",t.data)}subscribe(){this.subscribed||(this.subscriptionPending=!0,this.subscriptionCancelled=!1,this.authorize(this.pusher.connection.socket_id,(t,e)=>{t?(this.subscriptionPending=!1,V.error(t.toString()),this.emit("pusher:subscription_error",Object.assign({},{type:"AuthError",error:t.message},t instanceof y?{status:t.status}:{}))):this.pusher.send_event("pusher:subscribe",{auth:e.auth,channel_data:e.channel_data,channel:this.name})}))}unsubscribe(){this.subscribed=!1,this.pusher.send_event("pusher:unsubscribe",{channel:this.name})}cancelSubscription(){this.subscriptionCancelled=!0}reinstateSubscription(){this.subscriptionCancelled=!1}}class xt extends Ot{authorize(t,e){return this.pusher.config.channelAuthorizer({channelName:this.name,socketId:t},e)}}class Lt{constructor(){this.reset()}get(t){return Object.prototype.hasOwnProperty.call(this.members,t)?{id:t,info:this.members[t]}:null}each(t){M(this.members,(e,n)=>{t(this.get(n))})}setMyID(t){this.myID=t}onSubscription(t){this.members=t.presence.hash,this.count=t.presence.count,this.me=this.get(this.myID)}addMember(t){return null===this.get(t.user_id)&&this.count++,this.members[t.user_id]=t.user_info,this.get(t.user_id)}removeMember(t){var e=this.get(t.user_id);return e&&(delete this.members[t.user_id],this.count--),e}reset(){this.members={},this.count=0,this.myID=null,this.me=null}}var At=function(t,e,n,i){return new(n||(n=Promise))((function(r,s){function o(t){try{c(i.next(t))}catch(t){s(t)}}function a(t){try{c(i.throw(t))}catch(t){s(t)}}function c(t){var e;t.done?r(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(o,a)}c((i=i.apply(t,e||[])).next())}))};class Rt extends xt{constructor(t,e){super(t,e),this.members=new Lt}authorize(t,e){super.authorize(t,(t,n)=>At(this,void 0,void 0,(function*(){if(!t)if(null!=(n=n).channel_data){var i=JSON.parse(n.channel_data);this.members.setMyID(i.user_id)}else{if(yield this.pusher.user.signinDonePromise,null==this.pusher.user.user_data){let t=u("authorizationEndpoint");return V.error(`Invalid auth response for channel '${this.name}', expected 'channel_data' field. ${t}, or the user should be signed in.`),void e("Invalid auth response")}this.members.setMyID(this.pusher.user.user_data.id)}e(t,n)})))}handleEvent(t){var e=t.event;if(0===e.indexOf("pusher_internal:"))this.handleInternalEvent(t);else{var n=t.data,i={};t.user_id&&(i.user_id=t.user_id),this.emit(e,n,i)}}handleInternalEvent(t){var e=t.event,n=t.data;switch(e){case"pusher_internal:subscription_succeeded":this.handleSubscriptionSucceededEvent(t);break;case"pusher_internal:subscription_count":this.handleSubscriptionCountEvent(t);break;case"pusher_internal:member_added":var i=this.members.addMember(n);this.emit("pusher:member_added",i);break;case"pusher_internal:member_removed":var r=this.members.removeMember(n);r&&this.emit("pusher:member_removed",r)}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):(this.members.onSubscription(t.data),this.emit("pusher:subscription_succeeded",this.members))}disconnect(){this.members.reset(),super.disconnect()}}var It=n(1),Dt=n(0);class jt extends xt{constructor(t,e,n){super(t,e),this.key=null,this.nacl=n}authorize(t,e){super.authorize(t,(t,n)=>{if(t)return void e(t,n);let i=n.shared_secret;i?(this.key=Object(Dt.decode)(i),delete n.shared_secret,e(null,n)):e(new Error("No shared_secret key in auth payload for encrypted channel: "+this.name),null)})}trigger(t,e){throw new v("Client events are not currently supported for encrypted channels")}handleEvent(t){var e=t.event,n=t.data;0!==e.indexOf("pusher_internal:")&&0!==e.indexOf("pusher:")?this.handleEncryptedEvent(e,n):super.handleEvent(t)}handleEncryptedEvent(t,e){if(!this.key)return void V.debug("Received encrypted event before key has been retrieved from the authEndpoint");if(!e.ciphertext||!e.nonce)return void V.error("Unexpected format for encrypted event, expected object with `ciphertext` and `nonce` fields, got: "+e);let n=Object(Dt.decode)(e.ciphertext);if(n.length{e?V.error(`Failed to make a request to the authEndpoint: ${s}. Unable to fetch new key, so dropping encrypted event`):(r=this.nacl.secretbox.open(n,i,this.key),null!==r?this.emit(t,this.getDataToEmit(r)):V.error("Failed to decrypt event with new key. Dropping encrypted event"))});this.emit(t,this.getDataToEmit(r))}getDataToEmit(t){let e=Object(It.decode)(t);try{return JSON.parse(e)}catch(t){return e}}}class Nt extends at{constructor(t,e){super(),this.state="initialized",this.connection=null,this.key=t,this.options=e,this.timeline=this.options.timeline,this.usingTLS=this.options.useTLS,this.errorCallbacks=this.buildErrorCallbacks(),this.connectionCallbacks=this.buildConnectionCallbacks(this.errorCallbacks),this.handshakeCallbacks=this.buildHandshakeCallbacks(this.errorCallbacks);var n=ue.getNetwork();n.bind("online",()=>{this.timeline.info({netinfo:"online"}),"connecting"!==this.state&&"unavailable"!==this.state||this.retryIn(0)}),n.bind("offline",()=>{this.timeline.info({netinfo:"offline"}),this.connection&&this.sendActivityCheck()}),this.updateStrategy()}connect(){this.connection||this.runner||(this.strategy.isSupported()?(this.updateState("connecting"),this.startConnecting(),this.setUnavailableTimer()):this.updateState("failed"))}send(t){return!!this.connection&&this.connection.send(t)}send_event(t,e,n){return!!this.connection&&this.connection.send_event(t,e,n)}disconnect(){this.disconnectInternally(),this.updateState("disconnected")}isUsingTLS(){return this.usingTLS}startConnecting(){var t=(e,n)=>{e?this.runner=this.strategy.connect(0,t):"error"===n.action?(this.emit("error",{type:"HandshakeError",error:n.error}),this.timeline.error({handshakeError:n.error})):(this.abortConnecting(),this.handshakeCallbacks[n.action](n))};this.runner=this.strategy.connect(0,t)}abortConnecting(){this.runner&&(this.runner.abort(),this.runner=null)}disconnectInternally(){(this.abortConnecting(),this.clearRetryTimer(),this.clearUnavailableTimer(),this.connection)&&this.abandonConnection().close()}updateStrategy(){this.strategy=this.options.getStrategy({key:this.key,timeline:this.timeline,useTLS:this.usingTLS})}retryIn(t){this.timeline.info({action:"retry",delay:t}),t>0&&this.emit("connecting_in",Math.round(t/1e3)),this.retryTimer=new I(t||0,()=>{this.disconnectInternally(),this.connect()})}clearRetryTimer(){this.retryTimer&&(this.retryTimer.ensureAborted(),this.retryTimer=null)}setUnavailableTimer(){this.unavailableTimer=new I(this.options.unavailableTimeout,()=>{this.updateState("unavailable")})}clearUnavailableTimer(){this.unavailableTimer&&this.unavailableTimer.ensureAborted()}sendActivityCheck(){this.stopActivityCheck(),this.connection.ping(),this.activityTimer=new I(this.options.pongTimeout,()=>{this.timeline.error({pong_timed_out:this.options.pongTimeout}),this.retryIn(0)})}resetActivityCheck(){this.stopActivityCheck(),this.connection&&!this.connection.handlesActivityChecks()&&(this.activityTimer=new I(this.activityTimeout,()=>{this.sendActivityCheck()}))}stopActivityCheck(){this.activityTimer&&this.activityTimer.ensureAborted()}buildConnectionCallbacks(t){return N({},t,{message:t=>{this.resetActivityCheck(),this.emit("message",t)},ping:()=>{this.send_event("pusher:pong",{})},activity:()=>{this.resetActivityCheck()},error:t=>{this.emit("error",t)},closed:()=>{this.abandonConnection(),this.shouldRetry()&&this.retryIn(1e3)}})}buildHandshakeCallbacks(t){return N({},t,{connected:t=>{this.activityTimeout=Math.min(this.options.activityTimeout,t.activityTimeout,t.connection.activityTimeout||1/0),this.clearUnavailableTimer(),this.setConnection(t.connection),this.socket_id=this.connection.id,this.updateState("connected",{socket_id:this.socket_id})}})}buildErrorCallbacks(){let t=t=>e=>{e.error&&this.emit("error",{type:"WebSocketError",error:e.error}),t(e)};return{tls_only:t(()=>{this.usingTLS=!0,this.updateStrategy(),this.retryIn(0)}),refused:t(()=>{this.disconnect()}),backoff:t(()=>{this.retryIn(1e3)}),retry:t(()=>{this.retryIn(0)})}}setConnection(t){for(var e in this.connection=t,this.connectionCallbacks)this.connection.bind(e,this.connectionCallbacks[e]);this.resetActivityCheck()}abandonConnection(){if(this.connection){for(var t in this.stopActivityCheck(),this.connectionCallbacks)this.connection.unbind(t,this.connectionCallbacks[t]);var e=this.connection;return this.connection=null,e}}updateState(t,e){var n=this.state;if(this.state=t,n!==t){var i=t;"connected"===i&&(i+=" with new socket ID "+e.socket_id),V.debug("State changed",n+" -> "+i),this.timeline.info({state:t,params:e}),this.emit("state_change",{previous:n,current:t}),this.emit(t,e)}}shouldRetry(){return"connecting"===this.state||"connected"===this.state}}class Ht{constructor(){this.channels={}}add(t,e){return this.channels[t]||(this.channels[t]=function(t,e){if(0===t.indexOf("private-encrypted-")){if(e.config.nacl)return Ut.createEncryptedChannel(t,e,e.config.nacl);let n="Tried to subscribe to a private-encrypted- channel but no nacl implementation available",i=u("encryptedChannelSupport");throw new v(`${n}. ${i}`)}if(0===t.indexOf("private-"))return Ut.createPrivateChannel(t,e);if(0===t.indexOf("presence-"))return Ut.createPresenceChannel(t,e);if(0===t.indexOf("#"))throw new d('Cannot create a channel with name "'+t+'".');return Ut.createChannel(t,e)}(t,e)),this.channels[t]}all(){return function(t){var e=[];return M(t,(function(t){e.push(t)})),e}(this.channels)}find(t){return this.channels[t]}remove(t){var e=this.channels[t];return delete this.channels[t],e}disconnect(){M(this.channels,(function(t){t.disconnect()}))}}var Ut={createChannels:()=>new Ht,createConnectionManager:(t,e)=>new Nt(t,e),createChannel:(t,e)=>new Ot(t,e),createPrivateChannel:(t,e)=>new xt(t,e),createPresenceChannel:(t,e)=>new Rt(t,e),createEncryptedChannel:(t,e,n)=>new jt(t,e,n),createTimelineSender:(t,e)=>new Et(t,e),createHandshake:(t,e)=>new Pt(t,e),createAssistantToTheTransportManager:(t,e,n)=>new _t(t,e,n)};class Mt{constructor(t){this.options=t||{},this.livesLeft=this.options.lives||1/0}getAssistant(t){return Ut.createAssistantToTheTransportManager(this,t,{minPingDelay:this.options.minPingDelay,maxPingDelay:this.options.maxPingDelay})}isAlive(){return this.livesLeft>0}reportDeath(){this.livesLeft-=1}}class zt{constructor(t,e){this.strategies=t,this.loop=Boolean(e.loop),this.failFast=Boolean(e.failFast),this.timeout=e.timeout,this.timeoutLimit=e.timeoutLimit}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){var n=this.strategies,i=0,r=this.timeout,s=null,o=(a,c)=>{c?e(null,c):(i+=1,this.loop&&(i%=n.length),i0&&(r=new I(n.timeout,(function(){s.abort(),i(!0)}))),s=t.connect(e,(function(t,e){t&&r&&r.isRunning()&&!n.failFast||(r&&r.ensureAborted(),i(t,e))})),{abort:function(){r&&r.ensureAborted(),s.abort()},forceMinPriority:function(t){s.forceMinPriority(t)}}}}class qt{constructor(t){this.strategies=t}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){return function(t,e,n){var i=B(t,(function(t,i,r,s){return t.connect(e,n(i,s))}));return{abort:function(){q(i,Bt)},forceMinPriority:function(t){q(i,(function(e){e.forceMinPriority(t)}))}}}(this.strategies,t,(function(t,n){return function(i,r){n[t].error=i,i?function(t){return function(t,e){for(var n=0;n=j.now()){var o=this.transports[i.transport];o&&(["ws","wss"].includes(i.transport)||r>3?(this.timeline.info({cached:!0,transport:i.transport,latency:i.latency}),s.push(new zt([o],{timeout:2*i.latency+1e3,failFast:!0}))):r++)}var a=j.now(),c=s.pop().connect(t,(function i(o,h){o?(Jt(n),s.length>0?(a=j.now(),c=s.pop().connect(t,i)):e(o)):(!function(t,e,n,i){var r=ue.getLocalStorage();if(r)try{r[Xt(t)]=G({timestamp:j.now(),transport:e,latency:n,cacheSkipCount:i})}catch(t){}}(n,h.transport.name,j.now()-a,r),e(null,h))}));return{abort:function(){c.abort()},forceMinPriority:function(e){t=e,c&&c.forceMinPriority(e)}}}}function Xt(t){return"pusherTransport"+(t?"TLS":"NonTLS")}function Jt(t){var e=ue.getLocalStorage();if(e)try{delete e[Xt(t)]}catch(t){}}class $t{constructor(t,{delay:e}){this.strategy=t,this.options={delay:e}}isSupported(){return this.strategy.isSupported()}connect(t,e){var n,i=this.strategy,r=new I(this.options.delay,(function(){n=i.connect(t,e)}));return{abort:function(){r.ensureAborted(),n&&n.abort()},forceMinPriority:function(e){t=e,n&&n.forceMinPriority(e)}}}}class Wt{constructor(t,e,n){this.test=t,this.trueBranch=e,this.falseBranch=n}isSupported(){return(this.test()?this.trueBranch:this.falseBranch).isSupported()}connect(t,e){return(this.test()?this.trueBranch:this.falseBranch).connect(t,e)}}class Gt{constructor(t){this.strategy=t}isSupported(){return this.strategy.isSupported()}connect(t,e){var n=this.strategy.connect(t,(function(t,i){i&&n.abort(),e(t,i)}));return n}}function Vt(t){return function(){return t.isSupported()}}var Yt=function(t,e,n){var i={};function r(e,r,s,o,a){var c=n(t,e,r,s,o,a);return i[e]=c,c}var s,o=Object.assign({},e,{hostNonTLS:t.wsHost+":"+t.wsPort,hostTLS:t.wsHost+":"+t.wssPort,httpPath:t.wsPath}),a=Object.assign({},o,{useTLS:!0}),c=Object.assign({},e,{hostNonTLS:t.httpHost+":"+t.httpPort,hostTLS:t.httpHost+":"+t.httpsPort,httpPath:t.httpPath}),h={loop:!0,timeout:15e3,timeoutLimit:6e4},u=new Mt({minPingDelay:1e4,maxPingDelay:t.activityTimeout}),l=new Mt({lives:2,minPingDelay:1e4,maxPingDelay:t.activityTimeout}),d=r("ws","ws",3,o,u),p=r("wss","ws",3,a,u),f=r("sockjs","sockjs",1,c),g=r("xhr_streaming","xhr_streaming",1,c,l),v=r("xdr_streaming","xdr_streaming",1,c,l),m=r("xhr_polling","xhr_polling",1,c),b=r("xdr_polling","xdr_polling",1,c),y=new zt([d],h),w=new zt([p],h),S=new zt([f],h),_=new zt([new Wt(Vt(g),g,v)],h),k=new zt([new Wt(Vt(m),m,b)],h),C=new zt([new Wt(Vt(_),new qt([_,new $t(k,{delay:4e3})]),k)],h),T=new Wt(Vt(C),C,S);return s=e.useTLS?new qt([y,new $t(T,{delay:2e3})]):new qt([y,new $t(w,{delay:2e3}),new $t(T,{delay:5e3})]),new Ft(new Gt(new Wt(Vt(d),s,T)),i,{ttl:18e5,timeline:e.timeline,useTLS:e.useTLS})},Qt={getRequest:function(t){var e=new window.XDomainRequest;return e.ontimeout=function(){t.emit("error",new p),t.close()},e.onerror=function(e){t.emit("error",e),t.close()},e.onprogress=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText)},e.onload=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText),t.emit("finished",200),t.close()},e},abortRequest:function(t){t.ontimeout=t.onerror=t.onprogress=t.onload=null,t.abort()}};class Kt extends at{constructor(t,e,n){super(),this.hooks=t,this.method=e,this.url=n}start(t){this.position=0,this.xhr=this.hooks.getRequest(this),this.unloader=()=>{this.close()},ue.addUnloadListener(this.unloader),this.xhr.open(this.method,this.url,!0),this.xhr.setRequestHeader&&this.xhr.setRequestHeader("Content-Type","application/json"),this.xhr.send(t)}close(){this.unloader&&(ue.removeUnloadListener(this.unloader),this.unloader=null),this.xhr&&(this.hooks.abortRequest(this.xhr),this.xhr=null)}onChunk(t,e){for(;;){var n=this.advanceBuffer(e);if(!n)break;this.emit("chunk",{status:t,data:n})}this.isBufferTooLong(e)&&this.emit("buffer_too_long")}advanceBuffer(t){var e=t.slice(this.position),n=e.indexOf("\n");return-1!==n?(this.position+=n+1,e.slice(0,n)):null}isBufferTooLong(t){return this.position===t.length&&t.length>262144}}var Zt;!function(t){t[t.CONNECTING=0]="CONNECTING",t[t.OPEN=1]="OPEN",t[t.CLOSED=3]="CLOSED"}(Zt||(Zt={}));var te=Zt,ee=1;function ne(t){var e=-1===t.indexOf("?")?"?":"&";return t+e+"t="+ +new Date+"&n="+ee++}function ie(t){return ue.randomInt(t)}var re,se=class{constructor(t,e){this.hooks=t,this.session=ie(1e3)+"/"+function(t){for(var e=[],n=0;n{this.onChunk(t)}),this.stream.bind("finished",t=>{this.hooks.onFinished(this,t)}),this.stream.bind("buffer_too_long",()=>{this.reconnect()});try{this.stream.start()}catch(t){j.defer(()=>{this.onError(t),this.onClose(1006,"Could not start streaming",!1)})}}closeStream(){this.stream&&(this.stream.unbind_all(),this.stream.close(),this.stream=null)}},oe={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr_streaming"+t.queryString},onHeartbeat:function(t){t.sendRaw("[]")},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ae={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr"+t.queryString},onHeartbeat:function(){},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){200===e?t.reconnect():t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ce={getRequest:function(t){var e=new(ue.getXHRAPI());return e.onreadystatechange=e.onprogress=function(){switch(e.readyState){case 3:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText);break;case 4:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText),t.emit("finished",e.status),t.close()}},e},abortRequest:function(t){t.onreadystatechange=null,t.abort()}},he={createStreamingSocket(t){return this.createSocket(oe,t)},createPollingSocket(t){return this.createSocket(ae,t)},createSocket:(t,e)=>new se(t,e),createXHR(t,e){return this.createRequest(ce,t,e)},createRequest:(t,e,n)=>new Kt(t,e,n),createXDR:function(t,e){return this.createRequest(Qt,t,e)}},ue={nextAuthCallbackID:1,auth_callbacks:{},ScriptReceivers:r,DependenciesReceivers:o,getDefaultStrategy:Yt,Transports:wt,transportConnectionInitializer:function(){var t=this;t.timeline.info(t.buildTimelineMessage({transport:t.name+(t.options.useTLS?"s":"")})),t.hooks.isInitialized()?t.changeState("initialized"):t.hooks.file?(t.changeState("initializing"),a.load(t.hooks.file,{useTLS:t.options.useTLS},(function(e,n){t.hooks.isInitialized()?(t.changeState("initialized"),n(!0)):(e&&t.onError(e),t.onClose(),n(!1))}))):t.onClose()},HTTPFactory:he,TimelineTransport:Z,getXHRAPI:()=>window.XMLHttpRequest,getWebSocketAPI:()=>window.WebSocket||window.MozWebSocket,setup(t){window.Pusher=t;var e=()=>{this.onDocumentBody(t.ready)};window.JSON?e():a.load("json2",{},e)},getDocument:()=>document,getProtocol(){return this.getDocument().location.protocol},getAuthorizers:()=>({ajax:w,jsonp:Y}),onDocumentBody(t){document.body?t():setTimeout(()=>{this.onDocumentBody(t)},0)},createJSONPRequest:(t,e)=>new K(t,e),createScriptRequest:t=>new Q(t),getLocalStorage(){try{return window.localStorage}catch(t){return}},createXHR(){return this.getXHRAPI()?this.createXMLHttpRequest():this.createMicrosoftXHR()},createXMLHttpRequest(){return new(this.getXHRAPI())},createMicrosoftXHR:()=>new ActiveXObject("Microsoft.XMLHTTP"),getNetwork:()=>St,createWebSocket(t){return new(this.getWebSocketAPI())(t)},createSocketRequest(t,e){if(this.isXHRSupported())return this.HTTPFactory.createXHR(t,e);if(this.isXDRSupported(0===e.indexOf("https:")))return this.HTTPFactory.createXDR(t,e);throw"Cross-origin HTTP requests are not supported"},isXHRSupported(){var t=this.getXHRAPI();return Boolean(t)&&void 0!==(new t).withCredentials},isXDRSupported(t){var e=t?"https:":"http:",n=this.getProtocol();return Boolean(window.XDomainRequest)&&n===e},addUnloadListener(t){void 0!==window.addEventListener?window.addEventListener("unload",t,!1):void 0!==window.attachEvent&&window.attachEvent("onunload",t)},removeUnloadListener(t){void 0!==window.addEventListener?window.removeEventListener("unload",t,!1):void 0!==window.detachEvent&&window.detachEvent("onunload",t)},randomInt:t=>Math.floor((window.crypto||window.msCrypto).getRandomValues(new Uint32Array(1))[0]/Math.pow(2,32)*t)};!function(t){t[t.ERROR=3]="ERROR",t[t.INFO=6]="INFO",t[t.DEBUG=7]="DEBUG"}(re||(re={}));var le=re;class de{constructor(t,e,n){this.key=t,this.session=e,this.events=[],this.options=n||{},this.sent=0,this.uniqueID=0}log(t,e){t<=this.options.level&&(this.events.push(N({},e,{timestamp:j.now()})),this.options.limit&&this.events.length>this.options.limit&&this.events.shift())}error(t){this.log(le.ERROR,t)}info(t){this.log(le.INFO,t)}debug(t){this.log(le.DEBUG,t)}isEmpty(){return 0===this.events.length}send(t,e){var n=N({session:this.session,bundle:this.sent+1,key:this.key,lib:"js",version:this.options.version,cluster:this.options.cluster,features:this.options.features,timeline:this.events},this.options.params);return this.events=[],t(n,(t,n)=>{t||this.sent++,e&&e(t,n)}),!0}generateUniqueID(){return this.uniqueID++,this.uniqueID}}class pe{constructor(t,e,n,i){this.name=t,this.priority=e,this.transport=n,this.options=i||{}}isSupported(){return this.transport.isSupported({useTLS:this.options.useTLS})}connect(t,e){if(!this.isSupported())return fe(new b,e);if(this.priority{n||(h(),r?r.close():i.close())},forceMinPriority:t=>{n||this.priority{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.UserAuthentication,n)}};var ye=t=>{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in n+="&channel_name="+encodeURIComponent(t.channelName),e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.ChannelAuthorization,n)}};function we(t){return t.httpHost?t.httpHost:t.cluster?`sockjs-${t.cluster}.pusher.com`:s.httpHost}function Se(t){return t.wsHost?t.wsHost:`ws-${t.cluster}.pusher.com`}function _e(t){return"https:"===ue.getProtocol()||!1!==t.forceTLS}function ke(t){return"enableStats"in t?t.enableStats:"disableStats"in t&&!t.disableStats}function Ce(t){const e=Object.assign(Object.assign({},s.userAuthentication),t.userAuthentication);return"customHandler"in e&&null!=e.customHandler?e.customHandler:be(e)}function Te(t,e){const n=function(t,e){let n;return"channelAuthorization"in t?n=Object.assign(Object.assign({},s.channelAuthorization),t.channelAuthorization):(n={transport:t.authTransport||s.authTransport,endpoint:t.authEndpoint||s.authEndpoint},"auth"in t&&("params"in t.auth&&(n.params=t.auth.params),"headers"in t.auth&&(n.headers=t.auth.headers)),"authorizer"in t&&(n.customHandler=((t,e,n)=>{const i={authTransport:e.transport,authEndpoint:e.endpoint,auth:{params:e.params,headers:e.headers}};return(e,r)=>{const s=t.channel(e.channelName);n(s,i).authorize(e.socketId,r)}})(e,n,t.authorizer))),n}(t,e);return"customHandler"in n&&null!=n.customHandler?n.customHandler:ye(n)}class Pe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on watchlist events for "+t)})),this.pusher=t,this.bindWatchlistInternalEvent()}handleEvent(t){t.data.events.forEach(t=>{this.emit(t.name,t)})}bindWatchlistInternalEvent(){this.pusher.connection.bind("message",t=>{"pusher_internal:watchlist_events"===t.event&&this.handleEvent(t)})}}var Ee=function(){let t,e;return{promise:new Promise((n,i)=>{t=n,e=i}),resolve:t,reject:e}};class Oe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on user for "+t)})),this.signin_requested=!1,this.user_data=null,this.serverToUserChannel=null,this.signinDonePromise=null,this._signinDoneResolve=null,this._onAuthorize=(t,e)=>{if(t)return V.warn("Error during signin: "+t),void this._cleanup();this.pusher.send_event("pusher:signin",{auth:e.auth,user_data:e.user_data})},this.pusher=t,this.pusher.connection.bind("state_change",({previous:t,current:e})=>{"connected"!==t&&"connected"===e&&this._signin(),"connected"===t&&"connected"!==e&&(this._cleanup(),this._newSigninPromiseIfNeeded())}),this.watchlist=new Pe(t),this.pusher.connection.bind("message",t=>{"pusher:signin_success"===t.event&&this._onSigninSuccess(t.data),this.serverToUserChannel&&this.serverToUserChannel.name===t.channel&&this.serverToUserChannel.handleEvent(t)})}signin(){this.signin_requested||(this.signin_requested=!0,this._signin())}_signin(){this.signin_requested&&(this._newSigninPromiseIfNeeded(),"connected"===this.pusher.connection.state&&this.pusher.config.userAuthenticator({socketId:this.pusher.connection.socket_id},this._onAuthorize))}_onSigninSuccess(t){try{this.user_data=JSON.parse(t.user_data)}catch(e){return V.error("Failed parsing user data after signin: "+t.user_data),void this._cleanup()}if("string"!=typeof this.user_data.id||""===this.user_data.id)return V.error("user_data doesn't contain an id. user_data: "+this.user_data),void this._cleanup();this._signinDoneResolve(),this._subscribeChannels()}_subscribeChannels(){this.serverToUserChannel=new Ot("#server-to-user-"+this.user_data.id,this.pusher),this.serverToUserChannel.bind_global((t,e)=>{0!==t.indexOf("pusher_internal:")&&0!==t.indexOf("pusher:")&&this.emit(t,e)}),(t=>{t.subscriptionPending&&t.subscriptionCancelled?t.reinstateSubscription():t.subscriptionPending||"connected"!==this.pusher.connection.state||t.subscribe()})(this.serverToUserChannel)}_cleanup(){this.user_data=null,this.serverToUserChannel&&(this.serverToUserChannel.unbind_all(),this.serverToUserChannel.disconnect(),this.serverToUserChannel=null),this.signin_requested&&this._signinDoneResolve()}_newSigninPromiseIfNeeded(){if(!this.signin_requested)return;if(this.signinDonePromise&&!this.signinDonePromise.done)return;const{promise:t,resolve:e,reject:n}=Ee();t.done=!1;const i=()=>{t.done=!0};t.then(i).catch(i),this.signinDonePromise=t,this._signinDoneResolve=e}}class xe{static ready(){xe.isReady=!0;for(var t=0,e=xe.instances.length;tue.getDefaultStrategy(this.config,t,ve),timeline:this.timeline,activityTimeout:this.config.activityTimeout,pongTimeout:this.config.pongTimeout,unavailableTimeout:this.config.unavailableTimeout,useTLS:Boolean(this.config.useTLS)}),this.connection.bind("connected",()=>{this.subscribeAll(),this.timelineSender&&this.timelineSender.send(this.connection.isUsingTLS())}),this.connection.bind("message",t=>{var e=0===t.event.indexOf("pusher_internal:");if(t.channel){var n=this.channel(t.channel);n&&n.handleEvent(t)}e||this.global_emitter.emit(t.event,t.data)}),this.connection.bind("connecting",()=>{this.channels.disconnect()}),this.connection.bind("disconnected",()=>{this.channels.disconnect()}),this.connection.bind("error",t=>{V.warn(t)}),xe.instances.push(this),this.timeline.info({instances:xe.instances.length}),this.user=new Oe(this),xe.isReady&&this.connect()}channel(t){return this.channels.find(t)}allChannels(){return this.channels.all()}connect(){if(this.connection.connect(),this.timelineSender&&!this.timelineSenderTimer){var t=this.connection.isUsingTLS(),e=this.timelineSender;this.timelineSenderTimer=new D(6e4,(function(){e.send(t)}))}}disconnect(){this.connection.disconnect(),this.timelineSenderTimer&&(this.timelineSenderTimer.ensureAborted(),this.timelineSenderTimer=null)}bind(t,e,n){return this.global_emitter.bind(t,e,n),this}unbind(t,e,n){return this.global_emitter.unbind(t,e,n),this}bind_global(t){return this.global_emitter.bind_global(t),this}unbind_global(t){return this.global_emitter.unbind_global(t),this}unbind_all(t){return this.global_emitter.unbind_all(),this}subscribeAll(){var t;for(t in this.channels.channels)this.channels.channels.hasOwnProperty(t)&&this.subscribe(t)}subscribe(t){var e=this.channels.add(t,this);return e.subscriptionPending&&e.subscriptionCancelled?e.reinstateSubscription():e.subscriptionPending||"connected"!==this.connection.state||e.subscribe(),e}unsubscribe(t){var e=this.channels.find(t);e&&e.subscriptionPending?e.cancelSubscription():(e=this.channels.remove(t))&&e.subscribed&&e.unsubscribe()}send_event(t,e,n){return this.connection.send_event(t,e,n)}shouldUseTLS(){return this.config.useTLS}signin(){this.user.signin()}}xe.instances=[],xe.isReady=!1,xe.logToConsole=!1,xe.Runtime=ue,xe.ScriptReceivers=ue.ScriptReceivers,xe.DependenciesReceivers=ue.DependenciesReceivers,xe.auth_callbacks=ue.auth_callbacks;var Le=e.default=xe;ue.setup(xe)}])})); +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pusher=e():t.Pusher=e()}(window,(function(){return function(t){var e={};function n(i){if(e[i])return e[i].exports;var r=e[i]={i:i,l:!1,exports:{}};return t[i].call(r.exports,r,r.exports,n),r.l=!0,r.exports}return n.m=t,n.c=e,n.d=function(t,e,i){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:i})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)n.d(i,r,function(e){return t[e]}.bind(null,r));return i},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=2)}([function(t,e,n){"use strict";var i,r=this&&this.__extends||(i=function(t,e){return(i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,e)},function(t,e){function n(){this.constructor=t}i(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0});var s=function(){function t(t){void 0===t&&(t="="),this._paddingCharacter=t}return t.prototype.encodedLength=function(t){return this._paddingCharacter?(t+2)/3*4|0:(8*t+5)/6|0},t.prototype.encode=function(t){for(var e="",n=0;n>>18&63),e+=this._encodeByte(i>>>12&63),e+=this._encodeByte(i>>>6&63),e+=this._encodeByte(i>>>0&63)}var r=t.length-n;if(r>0){i=t[n]<<16|(2===r?t[n+1]<<8:0);e+=this._encodeByte(i>>>18&63),e+=this._encodeByte(i>>>12&63),e+=2===r?this._encodeByte(i>>>6&63):this._paddingCharacter||"",e+=this._paddingCharacter||""}return e},t.prototype.maxDecodedLength=function(t){return this._paddingCharacter?t/4*3|0:(6*t+7)/8|0},t.prototype.decodedLength=function(t){return this.maxDecodedLength(t.length-this._getPaddingLength(t))},t.prototype.decode=function(t){if(0===t.length)return new Uint8Array(0);for(var e=this._getPaddingLength(t),n=t.length-e,i=new Uint8Array(this.maxDecodedLength(n)),r=0,s=0,o=0,a=0,c=0,h=0,u=0;s>>4,i[r++]=c<<4|h>>>2,i[r++]=h<<6|u,o|=256&a,o|=256&c,o|=256&h,o|=256&u;if(s>>4,o|=256&a,o|=256&c),s>>2,o|=256&h),s>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-15,e+=62-t>>>8&3,String.fromCharCode(e)},t.prototype._decodeChar=function(t){var e=256;return e+=(42-t&t-44)>>>8&-256+t-43+62,e+=(46-t&t-48)>>>8&-256+t-47+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},t.prototype._getPaddingLength=function(t){var e=0;if(this._paddingCharacter){for(var n=t.length-1;n>=0&&t[n]===this._paddingCharacter;n--)e++;if(t.length<4||e>2)throw new Error("Base64Coder: incorrect padding")}return e},t}();e.Coder=s;var o=new s;e.encode=function(t){return o.encode(t)},e.decode=function(t){return o.decode(t)};var a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return r(e,t),e.prototype._encodeByte=function(t){var e=t;return e+=65,e+=25-t>>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-13,e+=62-t>>>8&49,String.fromCharCode(e)},e.prototype._decodeChar=function(t){var e=256;return e+=(44-t&t-46)>>>8&-256+t-45+62,e+=(94-t&t-96)>>>8&-256+t-95+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},e}(s);e.URLSafeCoder=a;var c=new a;e.encodeURLSafe=function(t){return c.encode(t)},e.decodeURLSafe=function(t){return c.decode(t)},e.encodedLength=function(t){return o.encodedLength(t)},e.maxDecodedLength=function(t){return o.maxDecodedLength(t)},e.decodedLength=function(t){return o.decodedLength(t)}},function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var i="utf8: invalid source encoding";function r(t){for(var e=0,n=0;n=t.length-1)throw new Error("utf8: invalid string");n++,e+=4}}return e}e.encode=function(t){for(var e=new Uint8Array(r(t)),n=0,i=0;i>6,e[n++]=128|63&s):s<55296?(e[n++]=224|s>>12,e[n++]=128|s>>6&63,e[n++]=128|63&s):(i++,s=(1023&s)<<10,s|=1023&t.charCodeAt(i),s+=65536,e[n++]=240|s>>18,e[n++]=128|s>>12&63,e[n++]=128|s>>6&63,e[n++]=128|63&s)}return e},e.encodedLength=r,e.decode=function(t){for(var e=[],n=0;n=t.length)throw new Error(i);if(128!=(192&(o=t[++n])))throw new Error(i);r=(31&r)<<6|63&o,s=128}else if(r<240){if(n>=t.length-1)throw new Error(i);var o=t[++n],a=t[++n];if(128!=(192&o)||128!=(192&a))throw new Error(i);r=(15&r)<<12|(63&o)<<6|63&a,s=2048}else{if(!(r<248))throw new Error(i);if(n>=t.length-2)throw new Error(i);o=t[++n],a=t[++n];var c=t[++n];if(128!=(192&o)||128!=(192&a)||128!=(192&c))throw new Error(i);r=(15&r)<<18|(63&o)<<12|(63&a)<<6|63&c,s=65536}if(r=55296&&r<=57343)throw new Error(i);if(r>=65536){if(r>1114111)throw new Error(i);r-=65536,e.push(String.fromCharCode(55296|r>>10)),r=56320|1023&r}}e.push(String.fromCharCode(r))}return e.join("")}},function(t,e,n){t.exports=n(3).default},function(t,e,n){"use strict";n.r(e);class i{constructor(t,e){this.lastId=0,this.prefix=t,this.name=e}create(t){this.lastId++;var e=this.lastId,n=this.prefix+e,i=this.name+"["+e+"]",r=!1,s=function(){r||(t.apply(null,arguments),r=!0)};return this[e]=s,{number:e,id:n,name:i,callback:s}}remove(t){delete this[t.number]}}var r=new i("_pusher_script_","Pusher.ScriptReceivers"),s={VERSION:"8.4.0",PROTOCOL:7,wsPort:80,wssPort:443,wsPath:"",httpHost:"sockjs.pusher.com",httpPort:80,httpsPort:443,httpPath:"/pusher",stats_host:"stats.pusher.com",authEndpoint:"/pusher/auth",authTransport:"ajax",activityTimeout:12e4,pongTimeout:3e4,unavailableTimeout:1e4,userAuthentication:{endpoint:"/pusher/user-auth",transport:"ajax"},channelAuthorization:{endpoint:"/pusher/auth",transport:"ajax"},cdn_http:"http://js.pusher.com",cdn_https:"https://js.pusher.com",dependency_suffix:""};var o=new i("_pusher_dependencies","Pusher.DependenciesReceivers"),a=new class{constructor(t){this.options=t,this.receivers=t.receivers||r,this.loading={}}load(t,e,n){var i=this;if(i.loading[t]&&i.loading[t].length>0)i.loading[t].push(n);else{i.loading[t]=[n];var r=ue.createScriptRequest(i.getPath(t,e)),s=i.receivers.create((function(e){if(i.receivers.remove(s),i.loading[t]){var n=i.loading[t];delete i.loading[t];for(var o=function(t){t||r.cleanup()},a=0;a>>6)+S(128|63&e):S(224|e>>>12&15)+S(128|e>>>6&63)+S(128|63&e)},E=function(t){return t.replace(/[^\x00-\x7F]/g,P)},O=function(t){var e=[0,2,1][t.length%3],n=t.charCodeAt(0)<<16|(t.length>1?t.charCodeAt(1):0)<<8|(t.length>2?t.charCodeAt(2):0);return[_.charAt(n>>>18),_.charAt(n>>>12&63),e>=2?"=":_.charAt(n>>>6&63),e>=1?"=":_.charAt(63&n)].join("")},x=window.btoa||function(t){return t.replace(/[\s\S]{1,3}/g,O)};var L=class{constructor(t,e,n,i){this.clear=e,this.timer=t(()=>{this.timer&&(this.timer=i(this.timer))},n)}isRunning(){return null!==this.timer}ensureAborted(){this.timer&&(this.clear(this.timer),this.timer=null)}};function A(t){window.clearTimeout(t)}function R(t){window.clearInterval(t)}class I extends L{constructor(t,e){super(setTimeout,A,t,(function(t){return e(),null}))}}class D extends L{constructor(t,e){super(setInterval,R,t,(function(t){return e(),t}))}}var j={now:()=>Date.now?Date.now():(new Date).valueOf(),defer:t=>new I(0,t),method(t,...e){var n=Array.prototype.slice.call(arguments,1);return function(e){return e[t].apply(e,n.concat(arguments))}}};function N(t,...e){for(var n=0;n{window.console&&window.console.log&&window.console.log(t)}}debug(...t){this.log(this.globalLog,t)}warn(...t){this.log(this.globalLogWarn,t)}error(...t){this.log(this.globalLogError,t)}globalLogWarn(t){window.console&&window.console.warn?window.console.warn(t):this.globalLog(t)}globalLogError(t){window.console&&window.console.error?window.console.error(t):this.globalLogWarn(t)}log(t,...e){var n=H.apply(this,arguments);if(Le.log)Le.log(n);else if(Le.logToConsole){t.bind(this)(n)}}},Y=function(t,e,n,i,r){void 0===n.headers&&null==n.headersProvider||V.warn(`To send headers with the ${i.toString()} request, you must use AJAX, rather than JSONP.`);var s=t.nextAuthCallbackID.toString();t.nextAuthCallbackID++;var o=t.getDocument(),a=o.createElement("script");t.auth_callbacks[s]=function(t){r(null,t)};var c="Pusher.auth_callbacks['"+s+"']";a.src=n.endpoint+"?callback="+encodeURIComponent(c)+"&"+e;var h=o.getElementsByTagName("head")[0]||o.documentElement;h.insertBefore(a,h.firstChild)};class Q{constructor(t){this.src=t}send(t){var e=this,n="Error loading "+e.src;e.script=document.createElement("script"),e.script.id=t.id,e.script.src=e.src,e.script.type="text/javascript",e.script.charset="UTF-8",e.script.addEventListener?(e.script.onerror=function(){t.callback(n)},e.script.onload=function(){t.callback(null)}):e.script.onreadystatechange=function(){"loaded"!==e.script.readyState&&"complete"!==e.script.readyState||t.callback(null)},void 0===e.script.async&&document.attachEvent&&/opera/i.test(navigator.userAgent)?(e.errorScript=document.createElement("script"),e.errorScript.id=t.id+"_error",e.errorScript.text=t.name+"('"+n+"');",e.script.async=e.errorScript.async=!1):e.script.async=!0;var i=document.getElementsByTagName("head")[0];i.insertBefore(e.script,i.firstChild),e.errorScript&&i.insertBefore(e.errorScript,e.script.nextSibling)}cleanup(){this.script&&(this.script.onload=this.script.onerror=null,this.script.onreadystatechange=null),this.script&&this.script.parentNode&&this.script.parentNode.removeChild(this.script),this.errorScript&&this.errorScript.parentNode&&this.errorScript.parentNode.removeChild(this.errorScript),this.script=null,this.errorScript=null}}class K{constructor(t,e){this.url=t,this.data=e}send(t){if(!this.request){var e=W(this.data),n=this.url+"/"+t.number+"?"+e;this.request=ue.createScriptRequest(n),this.request.send(t)}}cleanup(){this.request&&this.request.cleanup()}}var Z={name:"jsonp",getAgent:function(t,e){return function(n,i){var s="http"+(e?"s":"")+"://"+(t.host||t.options.host)+t.options.path,o=ue.createJSONPRequest(s,n),a=ue.ScriptReceivers.create((function(e,n){r.remove(a),o.cleanup(),n&&n.host&&(t.host=n.host),i&&i(e,n)}));o.send(a)}}};function tt(t,e,n){return t+(e.useTLS?"s":"")+"://"+(e.useTLS?e.hostTLS:e.hostNonTLS)+n}function et(t,e){return"/app/"+t+("?protocol="+s.PROTOCOL+"&client=js&version="+s.VERSION+(e?"&"+e:""))}var nt={getInitial:function(t,e){return tt("ws",e,(e.httpPath||"")+et(t,"flash=false"))}},it={getInitial:function(t,e){return tt("http",e,(e.httpPath||"/pusher")+et(t))}},rt={getInitial:function(t,e){return tt("http",e,e.httpPath||"/pusher")},getPath:function(t,e){return et(t)}};class st{constructor(){this._callbacks={}}get(t){return this._callbacks[ot(t)]}add(t,e,n){var i=ot(t);this._callbacks[i]=this._callbacks[i]||[],this._callbacks[i].push({fn:e,context:n})}remove(t,e,n){if(t||e||n){var i=t?[ot(t)]:z(this._callbacks);e||n?this.removeCallback(i,e,n):this.removeAllCallbacks(i)}else this._callbacks={}}removeCallback(t,e,n){q(t,(function(t){this._callbacks[t]=F(this._callbacks[t]||[],(function(t){return e&&e!==t.fn||n&&n!==t.context})),0===this._callbacks[t].length&&delete this._callbacks[t]}),this)}removeAllCallbacks(t){q(t,(function(t){delete this._callbacks[t]}),this)}}function ot(t){return"_"+t}class at{constructor(t){this.callbacks=new st,this.global_callbacks=[],this.failThrough=t}bind(t,e,n){return this.callbacks.add(t,e,n),this}bind_global(t){return this.global_callbacks.push(t),this}unbind(t,e,n){return this.callbacks.remove(t,e,n),this}unbind_global(t){return t?(this.global_callbacks=F(this.global_callbacks||[],e=>e!==t),this):(this.global_callbacks=[],this)}unbind_all(){return this.unbind(),this.unbind_global(),this}emit(t,e,n){for(var i=0;i0)for(i=0;i{this.onError(t),this.changeState("closed")}),!1}return this.bindListeners(),V.debug("Connecting",{transport:this.name,url:t}),this.changeState("connecting"),!0}close(){return!!this.socket&&(this.socket.close(),!0)}send(t){return"open"===this.state&&(j.defer(()=>{this.socket&&this.socket.send(t)}),!0)}ping(){"open"===this.state&&this.supportsPing()&&this.socket.ping()}onOpen(){this.hooks.beforeOpen&&this.hooks.beforeOpen(this.socket,this.hooks.urls.getPath(this.key,this.options)),this.changeState("open"),this.socket.onopen=void 0}onError(t){this.emit("error",{type:"WebSocketError",error:t}),this.timeline.error(this.buildTimelineMessage({error:t.toString()}))}onClose(t){t?this.changeState("closed",{code:t.code,reason:t.reason,wasClean:t.wasClean}):this.changeState("closed"),this.unbindListeners(),this.socket=void 0}onMessage(t){this.emit("message",t)}onActivity(){this.emit("activity")}bindListeners(){this.socket.onopen=()=>{this.onOpen()},this.socket.onerror=t=>{this.onError(t)},this.socket.onclose=t=>{this.onClose(t)},this.socket.onmessage=t=>{this.onMessage(t)},this.supportsPing()&&(this.socket.onactivity=()=>{this.onActivity()})}unbindListeners(){this.socket&&(this.socket.onopen=void 0,this.socket.onerror=void 0,this.socket.onclose=void 0,this.socket.onmessage=void 0,this.supportsPing()&&(this.socket.onactivity=void 0))}changeState(t,e){this.state=t,this.timeline.info(this.buildTimelineMessage({state:t,params:e})),this.emit(t,e)}buildTimelineMessage(t){return N({cid:this.id},t)}}class ht{constructor(t){this.hooks=t}isSupported(t){return this.hooks.isSupported(t)}createConnection(t,e,n,i){return new ct(this.hooks,t,e,n,i)}}var ut=new ht({urls:nt,handlesActivityChecks:!1,supportsPing:!1,isInitialized:function(){return Boolean(ue.getWebSocketAPI())},isSupported:function(){return Boolean(ue.getWebSocketAPI())},getSocket:function(t){return ue.createWebSocket(t)}}),lt={urls:it,handlesActivityChecks:!1,supportsPing:!0,isInitialized:function(){return!0}},dt=N({getSocket:function(t){return ue.HTTPFactory.createStreamingSocket(t)}},lt),pt=N({getSocket:function(t){return ue.HTTPFactory.createPollingSocket(t)}},lt),ft={isSupported:function(){return ue.isXHRSupported()}},gt={ws:ut,xhr_streaming:new ht(N({},dt,ft)),xhr_polling:new ht(N({},pt,ft))},vt=new ht({file:"sockjs",urls:rt,handlesActivityChecks:!0,supportsPing:!1,isSupported:function(){return!0},isInitialized:function(){return void 0!==window.SockJS},getSocket:function(t,e){return new window.SockJS(t,null,{js_path:a.getPath("sockjs",{useTLS:e.useTLS}),ignore_null_origin:e.ignoreNullOrigin})},beforeOpen:function(t,e){t.send(JSON.stringify({path:e}))}}),mt={isSupported:function(t){return ue.isXDRSupported(t.useTLS)}},bt=new ht(N({},dt,mt)),yt=new ht(N({},pt,mt));gt.xdr_streaming=bt,gt.xdr_polling=yt,gt.sockjs=vt;var wt=gt;var St=new class extends at{constructor(){super();var t=this;void 0!==window.addEventListener&&(window.addEventListener("online",(function(){t.emit("online")}),!1),window.addEventListener("offline",(function(){t.emit("offline")}),!1))}isOnline(){return void 0===window.navigator.onLine||window.navigator.onLine}};class _t{constructor(t,e,n){this.manager=t,this.transport=e,this.minPingDelay=n.minPingDelay,this.maxPingDelay=n.maxPingDelay,this.pingDelay=void 0}createConnection(t,e,n,i){i=N({},i,{activityTimeout:this.pingDelay});var r=this.transport.createConnection(t,e,n,i),s=null,o=function(){r.unbind("open",o),r.bind("closed",a),s=j.now()},a=t=>{if(r.unbind("closed",a),1002===t.code||1003===t.code)this.manager.reportDeath();else if(!t.wasClean&&s){var e=j.now()-s;e<2*this.maxPingDelay&&(this.manager.reportDeath(),this.pingDelay=Math.max(e/2,this.minPingDelay))}};return r.bind("open",o),r}isSupported(t){return this.manager.isAlive()&&this.transport.isSupported(t)}}const kt={decodeMessage:function(t){try{var e=JSON.parse(t.data),n=e.data;if("string"==typeof n)try{n=JSON.parse(e.data)}catch(t){}var i={event:e.event,channel:e.channel,data:n};return e.user_id&&(i.user_id=e.user_id),i}catch(e){throw{type:"MessageParseError",error:e,data:t.data}}},encodeMessage:function(t){return JSON.stringify(t)},processHandshake:function(t){var e=kt.decodeMessage(t);if("pusher:connection_established"===e.event){if(!e.data.activity_timeout)throw"No activity timeout specified in handshake";return{action:"connected",id:e.data.socket_id,activityTimeout:1e3*e.data.activity_timeout}}if("pusher:error"===e.event)return{action:this.getCloseAction(e.data),error:this.getCloseError(e.data)};throw"Invalid handshake"},getCloseAction:function(t){return t.code<4e3?t.code>=1002&&t.code<=1004?"backoff":null:4e3===t.code?"tls_only":t.code<4100?"refused":t.code<4200?"backoff":t.code<4300?"retry":"refused"},getCloseError:function(t){return 1e3!==t.code&&1001!==t.code?{type:"PusherError",data:{code:t.code,message:t.reason||t.message}}:null}};var Ct=kt;class Tt extends at{constructor(t,e){super(),this.id=t,this.transport=e,this.activityTimeout=e.activityTimeout,this.bindListeners()}handlesActivityChecks(){return this.transport.handlesActivityChecks()}send(t){return this.transport.send(t)}send_event(t,e,n){var i={event:t,data:e};return n&&(i.channel=n),V.debug("Event sent",i),this.send(Ct.encodeMessage(i))}ping(){this.transport.supportsPing()?this.transport.ping():this.send_event("pusher:ping",{})}close(){this.transport.close()}bindListeners(){var t={message:t=>{var e;try{e=Ct.decodeMessage(t)}catch(e){this.emit("error",{type:"MessageParseError",error:e,data:t.data})}if(void 0!==e){switch(V.debug("Event recd",e),e.event){case"pusher:error":this.emit("error",{type:"PusherError",data:e.data});break;case"pusher:ping":this.emit("ping");break;case"pusher:pong":this.emit("pong")}this.emit("message",e)}},activity:()=>{this.emit("activity")},error:t=>{this.emit("error",t)},closed:t=>{e(),t&&t.code&&this.handleCloseEvent(t),this.transport=null,this.emit("closed")}},e=()=>{M(t,(t,e)=>{this.transport.unbind(e,t)})};M(t,(t,e)=>{this.transport.bind(e,t)})}handleCloseEvent(t){var e=Ct.getCloseAction(t),n=Ct.getCloseError(t);n&&this.emit("error",n),e&&this.emit(e,{action:e,error:n})}}class Pt{constructor(t,e){this.transport=t,this.callback=e,this.bindListeners()}close(){this.unbindListeners(),this.transport.close()}bindListeners(){this.onMessage=t=>{var e;this.unbindListeners();try{e=Ct.processHandshake(t)}catch(t){return this.finish("error",{error:t}),void this.transport.close()}"connected"===e.action?this.finish("connected",{connection:new Tt(e.id,this.transport),activityTimeout:e.activityTimeout}):(this.finish(e.action,{error:e.error}),this.transport.close())},this.onClosed=t=>{this.unbindListeners();var e=Ct.getCloseAction(t)||"backoff",n=Ct.getCloseError(t);this.finish(e,{error:n})},this.transport.bind("message",this.onMessage),this.transport.bind("closed",this.onClosed)}unbindListeners(){this.transport.unbind("message",this.onMessage),this.transport.unbind("closed",this.onClosed)}finish(t,e){this.callback(N({transport:this.transport,action:t},e))}}class Et{constructor(t,e){this.timeline=t,this.options=e||{}}send(t,e){this.timeline.isEmpty()||this.timeline.send(ue.TimelineTransport.getAgent(this,t),e)}}class Ot extends at{constructor(t,e){super((function(e,n){V.debug("No callbacks on "+t+" for "+e)})),this.name=t,this.pusher=e,this.subscribed=!1,this.subscriptionPending=!1,this.subscriptionCancelled=!1}authorize(t,e){return e(null,{auth:""})}trigger(t,e){if(0!==t.indexOf("client-"))throw new l("Event '"+t+"' does not start with 'client-'");if(!this.subscribed){var n=u("triggeringClientEvents");V.warn("Client event triggered before channel 'subscription_succeeded' event . "+n)}return this.pusher.send_event(t,e,this.name)}disconnect(){this.subscribed=!1,this.subscriptionPending=!1}handleEvent(t){var e=t.event,n=t.data;if("pusher_internal:subscription_succeeded"===e)this.handleSubscriptionSucceededEvent(t);else if("pusher_internal:subscription_count"===e)this.handleSubscriptionCountEvent(t);else if(0!==e.indexOf("pusher_internal:")){this.emit(e,n,{})}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):this.emit("pusher:subscription_succeeded",t.data)}handleSubscriptionCountEvent(t){t.data.subscription_count&&(this.subscriptionCount=t.data.subscription_count),this.emit("pusher:subscription_count",t.data)}subscribe(){this.subscribed||(this.subscriptionPending=!0,this.subscriptionCancelled=!1,this.authorize(this.pusher.connection.socket_id,(t,e)=>{t?(this.subscriptionPending=!1,V.error(t.toString()),this.emit("pusher:subscription_error",Object.assign({},{type:"AuthError",error:t.message},t instanceof y?{status:t.status}:{}))):this.pusher.send_event("pusher:subscribe",{auth:e.auth,channel_data:e.channel_data,channel:this.name})}))}unsubscribe(){this.subscribed=!1,this.pusher.send_event("pusher:unsubscribe",{channel:this.name})}cancelSubscription(){this.subscriptionCancelled=!0}reinstateSubscription(){this.subscriptionCancelled=!1}}class xt extends Ot{authorize(t,e){return this.pusher.config.channelAuthorizer({channelName:this.name,socketId:t},e)}}class Lt{constructor(){this.reset()}get(t){return Object.prototype.hasOwnProperty.call(this.members,t)?{id:t,info:this.members[t]}:null}each(t){M(this.members,(e,n)=>{t(this.get(n))})}setMyID(t){this.myID=t}onSubscription(t){this.members=t.presence.hash,this.count=t.presence.count,this.me=this.get(this.myID)}addMember(t){return null===this.get(t.user_id)&&this.count++,this.members[t.user_id]=t.user_info,this.get(t.user_id)}removeMember(t){var e=this.get(t.user_id);return e&&(delete this.members[t.user_id],this.count--),e}reset(){this.members={},this.count=0,this.myID=null,this.me=null}}var At=function(t,e,n,i){return new(n||(n=Promise))((function(r,s){function o(t){try{c(i.next(t))}catch(t){s(t)}}function a(t){try{c(i.throw(t))}catch(t){s(t)}}function c(t){var e;t.done?r(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(o,a)}c((i=i.apply(t,e||[])).next())}))};class Rt extends xt{constructor(t,e){super(t,e),this.members=new Lt}authorize(t,e){super.authorize(t,(t,n)=>At(this,void 0,void 0,(function*(){if(!t)if(null!=(n=n).channel_data){var i=JSON.parse(n.channel_data);this.members.setMyID(i.user_id)}else{if(yield this.pusher.user.signinDonePromise,null==this.pusher.user.user_data){let t=u("authorizationEndpoint");return V.error(`Invalid auth response for channel '${this.name}', expected 'channel_data' field. ${t}, or the user should be signed in.`),void e("Invalid auth response")}this.members.setMyID(this.pusher.user.user_data.id)}e(t,n)})))}handleEvent(t){var e=t.event;if(0===e.indexOf("pusher_internal:"))this.handleInternalEvent(t);else{var n=t.data,i={};t.user_id&&(i.user_id=t.user_id),this.emit(e,n,i)}}handleInternalEvent(t){var e=t.event,n=t.data;switch(e){case"pusher_internal:subscription_succeeded":this.handleSubscriptionSucceededEvent(t);break;case"pusher_internal:subscription_count":this.handleSubscriptionCountEvent(t);break;case"pusher_internal:member_added":var i=this.members.addMember(n);this.emit("pusher:member_added",i);break;case"pusher_internal:member_removed":var r=this.members.removeMember(n);r&&this.emit("pusher:member_removed",r)}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):(this.members.onSubscription(t.data),this.emit("pusher:subscription_succeeded",this.members))}disconnect(){this.members.reset(),super.disconnect()}}var It=n(1),Dt=n(0);class jt extends xt{constructor(t,e,n){super(t,e),this.key=null,this.nacl=n}authorize(t,e){super.authorize(t,(t,n)=>{if(t)return void e(t,n);let i=n.shared_secret;i?(this.key=Object(Dt.decode)(i),delete n.shared_secret,e(null,n)):e(new Error("No shared_secret key in auth payload for encrypted channel: "+this.name),null)})}trigger(t,e){throw new v("Client events are not currently supported for encrypted channels")}handleEvent(t){var e=t.event,n=t.data;0!==e.indexOf("pusher_internal:")&&0!==e.indexOf("pusher:")?this.handleEncryptedEvent(e,n):super.handleEvent(t)}handleEncryptedEvent(t,e){if(!this.key)return void V.debug("Received encrypted event before key has been retrieved from the authEndpoint");if(!e.ciphertext||!e.nonce)return void V.error("Unexpected format for encrypted event, expected object with `ciphertext` and `nonce` fields, got: "+e);let n=Object(Dt.decode)(e.ciphertext);if(n.length{e?V.error(`Failed to make a request to the authEndpoint: ${s}. Unable to fetch new key, so dropping encrypted event`):(r=this.nacl.secretbox.open(n,i,this.key),null!==r?this.emit(t,this.getDataToEmit(r)):V.error("Failed to decrypt event with new key. Dropping encrypted event"))});this.emit(t,this.getDataToEmit(r))}getDataToEmit(t){let e=Object(It.decode)(t);try{return JSON.parse(e)}catch(t){return e}}}class Nt extends at{constructor(t,e){super(),this.state="initialized",this.connection=null,this.key=t,this.options=e,this.timeline=this.options.timeline,this.usingTLS=this.options.useTLS,this.errorCallbacks=this.buildErrorCallbacks(),this.connectionCallbacks=this.buildConnectionCallbacks(this.errorCallbacks),this.handshakeCallbacks=this.buildHandshakeCallbacks(this.errorCallbacks);var n=ue.getNetwork();n.bind("online",()=>{this.timeline.info({netinfo:"online"}),"connecting"!==this.state&&"unavailable"!==this.state||this.retryIn(0)}),n.bind("offline",()=>{this.timeline.info({netinfo:"offline"}),this.connection&&this.sendActivityCheck()}),this.updateStrategy()}connect(){this.connection||this.runner||(this.strategy.isSupported()?(this.updateState("connecting"),this.startConnecting(),this.setUnavailableTimer()):this.updateState("failed"))}send(t){return!!this.connection&&this.connection.send(t)}send_event(t,e,n){return!!this.connection&&this.connection.send_event(t,e,n)}disconnect(){this.disconnectInternally(),this.updateState("disconnected")}isUsingTLS(){return this.usingTLS}startConnecting(){var t=(e,n)=>{e?this.runner=this.strategy.connect(0,t):"error"===n.action?(this.emit("error",{type:"HandshakeError",error:n.error}),this.timeline.error({handshakeError:n.error})):(this.abortConnecting(),this.handshakeCallbacks[n.action](n))};this.runner=this.strategy.connect(0,t)}abortConnecting(){this.runner&&(this.runner.abort(),this.runner=null)}disconnectInternally(){(this.abortConnecting(),this.clearRetryTimer(),this.clearUnavailableTimer(),this.connection)&&this.abandonConnection().close()}updateStrategy(){this.strategy=this.options.getStrategy({key:this.key,timeline:this.timeline,useTLS:this.usingTLS})}retryIn(t){this.timeline.info({action:"retry",delay:t}),t>0&&this.emit("connecting_in",Math.round(t/1e3)),this.retryTimer=new I(t||0,()=>{this.disconnectInternally(),this.connect()})}clearRetryTimer(){this.retryTimer&&(this.retryTimer.ensureAborted(),this.retryTimer=null)}setUnavailableTimer(){this.unavailableTimer=new I(this.options.unavailableTimeout,()=>{this.updateState("unavailable")})}clearUnavailableTimer(){this.unavailableTimer&&this.unavailableTimer.ensureAborted()}sendActivityCheck(){this.stopActivityCheck(),this.connection.ping(),this.activityTimer=new I(this.options.pongTimeout,()=>{this.timeline.error({pong_timed_out:this.options.pongTimeout}),this.retryIn(0)})}resetActivityCheck(){this.stopActivityCheck(),this.connection&&!this.connection.handlesActivityChecks()&&(this.activityTimer=new I(this.activityTimeout,()=>{this.sendActivityCheck()}))}stopActivityCheck(){this.activityTimer&&this.activityTimer.ensureAborted()}buildConnectionCallbacks(t){return N({},t,{message:t=>{this.resetActivityCheck(),this.emit("message",t)},ping:()=>{this.send_event("pusher:pong",{})},activity:()=>{this.resetActivityCheck()},error:t=>{this.emit("error",t)},closed:()=>{this.abandonConnection(),this.shouldRetry()&&this.retryIn(1e3)}})}buildHandshakeCallbacks(t){return N({},t,{connected:t=>{this.activityTimeout=Math.min(this.options.activityTimeout,t.activityTimeout,t.connection.activityTimeout||1/0),this.clearUnavailableTimer(),this.setConnection(t.connection),this.socket_id=this.connection.id,this.updateState("connected",{socket_id:this.socket_id})}})}buildErrorCallbacks(){let t=t=>e=>{e.error&&this.emit("error",{type:"WebSocketError",error:e.error}),t(e)};return{tls_only:t(()=>{this.usingTLS=!0,this.updateStrategy(),this.retryIn(0)}),refused:t(()=>{this.disconnect()}),backoff:t(()=>{this.retryIn(1e3)}),retry:t(()=>{this.retryIn(0)})}}setConnection(t){for(var e in this.connection=t,this.connectionCallbacks)this.connection.bind(e,this.connectionCallbacks[e]);this.resetActivityCheck()}abandonConnection(){if(this.connection){for(var t in this.stopActivityCheck(),this.connectionCallbacks)this.connection.unbind(t,this.connectionCallbacks[t]);var e=this.connection;return this.connection=null,e}}updateState(t,e){var n=this.state;if(this.state=t,n!==t){var i=t;"connected"===i&&(i+=" with new socket ID "+e.socket_id),V.debug("State changed",n+" -> "+i),this.timeline.info({state:t,params:e}),this.emit("state_change",{previous:n,current:t}),this.emit(t,e)}}shouldRetry(){return"connecting"===this.state||"connected"===this.state}}class Ht{constructor(){this.channels={}}add(t,e){return this.channels[t]||(this.channels[t]=function(t,e){if(0===t.indexOf("private-encrypted-")){if(e.config.nacl)return Ut.createEncryptedChannel(t,e,e.config.nacl);let n="Tried to subscribe to a private-encrypted- channel but no nacl implementation available",i=u("encryptedChannelSupport");throw new v(`${n}. ${i}`)}if(0===t.indexOf("private-"))return Ut.createPrivateChannel(t,e);if(0===t.indexOf("presence-"))return Ut.createPresenceChannel(t,e);if(0===t.indexOf("#"))throw new d('Cannot create a channel with name "'+t+'".');return Ut.createChannel(t,e)}(t,e)),this.channels[t]}all(){return function(t){var e=[];return M(t,(function(t){e.push(t)})),e}(this.channels)}find(t){return this.channels[t]}remove(t){var e=this.channels[t];return delete this.channels[t],e}disconnect(){M(this.channels,(function(t){t.disconnect()}))}}var Ut={createChannels:()=>new Ht,createConnectionManager:(t,e)=>new Nt(t,e),createChannel:(t,e)=>new Ot(t,e),createPrivateChannel:(t,e)=>new xt(t,e),createPresenceChannel:(t,e)=>new Rt(t,e),createEncryptedChannel:(t,e,n)=>new jt(t,e,n),createTimelineSender:(t,e)=>new Et(t,e),createHandshake:(t,e)=>new Pt(t,e),createAssistantToTheTransportManager:(t,e,n)=>new _t(t,e,n)};class Mt{constructor(t){this.options=t||{},this.livesLeft=this.options.lives||1/0}getAssistant(t){return Ut.createAssistantToTheTransportManager(this,t,{minPingDelay:this.options.minPingDelay,maxPingDelay:this.options.maxPingDelay})}isAlive(){return this.livesLeft>0}reportDeath(){this.livesLeft-=1}}class zt{constructor(t,e){this.strategies=t,this.loop=Boolean(e.loop),this.failFast=Boolean(e.failFast),this.timeout=e.timeout,this.timeoutLimit=e.timeoutLimit}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){var n=this.strategies,i=0,r=this.timeout,s=null,o=(a,c)=>{c?e(null,c):(i+=1,this.loop&&(i%=n.length),i0&&(r=new I(n.timeout,(function(){s.abort(),i(!0)}))),s=t.connect(e,(function(t,e){t&&r&&r.isRunning()&&!n.failFast||(r&&r.ensureAborted(),i(t,e))})),{abort:function(){r&&r.ensureAborted(),s.abort()},forceMinPriority:function(t){s.forceMinPriority(t)}}}}class qt{constructor(t){this.strategies=t}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){return function(t,e,n){var i=B(t,(function(t,i,r,s){return t.connect(e,n(i,s))}));return{abort:function(){q(i,Bt)},forceMinPriority:function(t){q(i,(function(e){e.forceMinPriority(t)}))}}}(this.strategies,t,(function(t,n){return function(i,r){n[t].error=i,i?function(t){return function(t,e){for(var n=0;n=j.now()){var o=this.transports[i.transport];o&&(["ws","wss"].includes(i.transport)||r>3?(this.timeline.info({cached:!0,transport:i.transport,latency:i.latency}),s.push(new zt([o],{timeout:2*i.latency+1e3,failFast:!0}))):r++)}var a=j.now(),c=s.pop().connect(t,(function i(o,h){o?(Jt(n),s.length>0?(a=j.now(),c=s.pop().connect(t,i)):e(o)):(!function(t,e,n,i){var r=ue.getLocalStorage();if(r)try{r[Xt(t)]=G({timestamp:j.now(),transport:e,latency:n,cacheSkipCount:i})}catch(t){}}(n,h.transport.name,j.now()-a,r),e(null,h))}));return{abort:function(){c.abort()},forceMinPriority:function(e){t=e,c&&c.forceMinPriority(e)}}}}function Xt(t){return"pusherTransport"+(t?"TLS":"NonTLS")}function Jt(t){var e=ue.getLocalStorage();if(e)try{delete e[Xt(t)]}catch(t){}}class $t{constructor(t,{delay:e}){this.strategy=t,this.options={delay:e}}isSupported(){return this.strategy.isSupported()}connect(t,e){var n,i=this.strategy,r=new I(this.options.delay,(function(){n=i.connect(t,e)}));return{abort:function(){r.ensureAborted(),n&&n.abort()},forceMinPriority:function(e){t=e,n&&n.forceMinPriority(e)}}}}class Wt{constructor(t,e,n){this.test=t,this.trueBranch=e,this.falseBranch=n}isSupported(){return(this.test()?this.trueBranch:this.falseBranch).isSupported()}connect(t,e){return(this.test()?this.trueBranch:this.falseBranch).connect(t,e)}}class Gt{constructor(t){this.strategy=t}isSupported(){return this.strategy.isSupported()}connect(t,e){var n=this.strategy.connect(t,(function(t,i){i&&n.abort(),e(t,i)}));return n}}function Vt(t){return function(){return t.isSupported()}}var Yt=function(t,e,n){var i={};function r(e,r,s,o,a){var c=n(t,e,r,s,o,a);return i[e]=c,c}var s,o=Object.assign({},e,{hostNonTLS:t.wsHost+":"+t.wsPort,hostTLS:t.wsHost+":"+t.wssPort,httpPath:t.wsPath}),a=Object.assign({},o,{useTLS:!0}),c=Object.assign({},e,{hostNonTLS:t.httpHost+":"+t.httpPort,hostTLS:t.httpHost+":"+t.httpsPort,httpPath:t.httpPath}),h={loop:!0,timeout:15e3,timeoutLimit:6e4},u=new Mt({minPingDelay:1e4,maxPingDelay:t.activityTimeout}),l=new Mt({lives:2,minPingDelay:1e4,maxPingDelay:t.activityTimeout}),d=r("ws","ws",3,o,u),p=r("wss","ws",3,a,u),f=r("sockjs","sockjs",1,c),g=r("xhr_streaming","xhr_streaming",1,c,l),v=r("xdr_streaming","xdr_streaming",1,c,l),m=r("xhr_polling","xhr_polling",1,c),b=r("xdr_polling","xdr_polling",1,c),y=new zt([d],h),w=new zt([p],h),S=new zt([f],h),_=new zt([new Wt(Vt(g),g,v)],h),k=new zt([new Wt(Vt(m),m,b)],h),C=new zt([new Wt(Vt(_),new qt([_,new $t(k,{delay:4e3})]),k)],h),T=new Wt(Vt(C),C,S);return s=e.useTLS?new qt([y,new $t(T,{delay:2e3})]):new qt([y,new $t(w,{delay:2e3}),new $t(T,{delay:5e3})]),new Ft(new Gt(new Wt(Vt(d),s,T)),i,{ttl:18e5,timeline:e.timeline,useTLS:e.useTLS})},Qt={getRequest:function(t){var e=new window.XDomainRequest;return e.ontimeout=function(){t.emit("error",new p),t.close()},e.onerror=function(e){t.emit("error",e),t.close()},e.onprogress=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText)},e.onload=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText),t.emit("finished",200),t.close()},e},abortRequest:function(t){t.ontimeout=t.onerror=t.onprogress=t.onload=null,t.abort()}};class Kt extends at{constructor(t,e,n){super(),this.hooks=t,this.method=e,this.url=n}start(t){this.position=0,this.xhr=this.hooks.getRequest(this),this.unloader=()=>{this.close()},ue.addUnloadListener(this.unloader),this.xhr.open(this.method,this.url,!0),this.xhr.setRequestHeader&&this.xhr.setRequestHeader("Content-Type","application/json"),this.xhr.send(t)}close(){this.unloader&&(ue.removeUnloadListener(this.unloader),this.unloader=null),this.xhr&&(this.hooks.abortRequest(this.xhr),this.xhr=null)}onChunk(t,e){for(;;){var n=this.advanceBuffer(e);if(!n)break;this.emit("chunk",{status:t,data:n})}this.isBufferTooLong(e)&&this.emit("buffer_too_long")}advanceBuffer(t){var e=t.slice(this.position),n=e.indexOf("\n");return-1!==n?(this.position+=n+1,e.slice(0,n)):null}isBufferTooLong(t){return this.position===t.length&&t.length>262144}}var Zt;!function(t){t[t.CONNECTING=0]="CONNECTING",t[t.OPEN=1]="OPEN",t[t.CLOSED=3]="CLOSED"}(Zt||(Zt={}));var te=Zt,ee=1;function ne(t){var e=-1===t.indexOf("?")?"?":"&";return t+e+"t="+ +new Date+"&n="+ee++}function ie(t){return ue.randomInt(t)}var re,se=class{constructor(t,e){this.hooks=t,this.session=ie(1e3)+"/"+function(t){for(var e=[],n=0;n{this.onChunk(t)}),this.stream.bind("finished",t=>{this.hooks.onFinished(this,t)}),this.stream.bind("buffer_too_long",()=>{this.reconnect()});try{this.stream.start()}catch(t){j.defer(()=>{this.onError(t),this.onClose(1006,"Could not start streaming",!1)})}}closeStream(){this.stream&&(this.stream.unbind_all(),this.stream.close(),this.stream=null)}},oe={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr_streaming"+t.queryString},onHeartbeat:function(t){t.sendRaw("[]")},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ae={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr"+t.queryString},onHeartbeat:function(){},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){200===e?t.reconnect():t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ce={getRequest:function(t){var e=new(ue.getXHRAPI());return e.onreadystatechange=e.onprogress=function(){switch(e.readyState){case 3:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText);break;case 4:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText),t.emit("finished",e.status),t.close()}},e},abortRequest:function(t){t.onreadystatechange=null,t.abort()}},he={createStreamingSocket(t){return this.createSocket(oe,t)},createPollingSocket(t){return this.createSocket(ae,t)},createSocket:(t,e)=>new se(t,e),createXHR(t,e){return this.createRequest(ce,t,e)},createRequest:(t,e,n)=>new Kt(t,e,n),createXDR:function(t,e){return this.createRequest(Qt,t,e)}},ue={nextAuthCallbackID:1,auth_callbacks:{},ScriptReceivers:r,DependenciesReceivers:o,getDefaultStrategy:Yt,Transports:wt,transportConnectionInitializer:function(){var t=this;t.timeline.info(t.buildTimelineMessage({transport:t.name+(t.options.useTLS?"s":"")})),t.hooks.isInitialized()?t.changeState("initialized"):t.hooks.file?(t.changeState("initializing"),a.load(t.hooks.file,{useTLS:t.options.useTLS},(function(e,n){t.hooks.isInitialized()?(t.changeState("initialized"),n(!0)):(e&&t.onError(e),t.onClose(),n(!1))}))):t.onClose()},HTTPFactory:he,TimelineTransport:Z,getXHRAPI:()=>window.XMLHttpRequest,getWebSocketAPI:()=>window.WebSocket||window.MozWebSocket,setup(t){window.Pusher=t;var e=()=>{this.onDocumentBody(t.ready)};window.JSON?e():a.load("json2",{},e)},getDocument:()=>document,getProtocol(){return this.getDocument().location.protocol},getAuthorizers:()=>({ajax:w,jsonp:Y}),onDocumentBody(t){document.body?t():setTimeout(()=>{this.onDocumentBody(t)},0)},createJSONPRequest:(t,e)=>new K(t,e),createScriptRequest:t=>new Q(t),getLocalStorage(){try{return window.localStorage}catch(t){return}},createXHR(){return this.getXHRAPI()?this.createXMLHttpRequest():this.createMicrosoftXHR()},createXMLHttpRequest(){return new(this.getXHRAPI())},createMicrosoftXHR:()=>new ActiveXObject("Microsoft.XMLHTTP"),getNetwork:()=>St,createWebSocket(t){return new(this.getWebSocketAPI())(t)},createSocketRequest(t,e){if(this.isXHRSupported())return this.HTTPFactory.createXHR(t,e);if(this.isXDRSupported(0===e.indexOf("https:")))return this.HTTPFactory.createXDR(t,e);throw"Cross-origin HTTP requests are not supported"},isXHRSupported(){var t=this.getXHRAPI();return Boolean(t)&&void 0!==(new t).withCredentials},isXDRSupported(t){var e=t?"https:":"http:",n=this.getProtocol();return Boolean(window.XDomainRequest)&&n===e},addUnloadListener(t){void 0!==window.addEventListener?window.addEventListener("unload",t,!1):void 0!==window.attachEvent&&window.attachEvent("onunload",t)},removeUnloadListener(t){void 0!==window.addEventListener?window.removeEventListener("unload",t,!1):void 0!==window.detachEvent&&window.detachEvent("onunload",t)},randomInt:t=>Math.floor((window.crypto||window.msCrypto).getRandomValues(new Uint32Array(1))[0]/Math.pow(2,32)*t)};!function(t){t[t.ERROR=3]="ERROR",t[t.INFO=6]="INFO",t[t.DEBUG=7]="DEBUG"}(re||(re={}));var le=re;class de{constructor(t,e,n){this.key=t,this.session=e,this.events=[],this.options=n||{},this.sent=0,this.uniqueID=0}log(t,e){t<=this.options.level&&(this.events.push(N({},e,{timestamp:j.now()})),this.options.limit&&this.events.length>this.options.limit&&this.events.shift())}error(t){this.log(le.ERROR,t)}info(t){this.log(le.INFO,t)}debug(t){this.log(le.DEBUG,t)}isEmpty(){return 0===this.events.length}send(t,e){var n=N({session:this.session,bundle:this.sent+1,key:this.key,lib:"js",version:this.options.version,cluster:this.options.cluster,features:this.options.features,timeline:this.events},this.options.params);return this.events=[],t(n,(t,n)=>{t||this.sent++,e&&e(t,n)}),!0}generateUniqueID(){return this.uniqueID++,this.uniqueID}}class pe{constructor(t,e,n,i){this.name=t,this.priority=e,this.transport=n,this.options=i||{}}isSupported(){return this.transport.isSupported({useTLS:this.options.useTLS})}connect(t,e){if(!this.isSupported())return fe(new b,e);if(this.priority{n||(h(),r?r.close():i.close())},forceMinPriority:t=>{n||this.priority{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.UserAuthentication,n)}};var ye=t=>{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in n+="&channel_name="+encodeURIComponent(t.channelName),e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.ChannelAuthorization,n)}};function we(t){return t.httpHost?t.httpHost:t.cluster?`sockjs-${t.cluster}.pusher.com`:s.httpHost}function Se(t){return t.wsHost?t.wsHost:`ws-${t.cluster}.pusher.com`}function _e(t){return"https:"===ue.getProtocol()||!1!==t.forceTLS}function ke(t){return"enableStats"in t?t.enableStats:"disableStats"in t&&!t.disableStats}function Ce(t){const e=Object.assign(Object.assign({},s.userAuthentication),t.userAuthentication);return"customHandler"in e&&null!=e.customHandler?e.customHandler:be(e)}function Te(t,e){const n=function(t,e){let n;return"channelAuthorization"in t?n=Object.assign(Object.assign({},s.channelAuthorization),t.channelAuthorization):(n={transport:t.authTransport||s.authTransport,endpoint:t.authEndpoint||s.authEndpoint},"auth"in t&&("params"in t.auth&&(n.params=t.auth.params),"headers"in t.auth&&(n.headers=t.auth.headers)),"authorizer"in t&&(n.customHandler=((t,e,n)=>{const i={authTransport:e.transport,authEndpoint:e.endpoint,auth:{params:e.params,headers:e.headers}};return(e,r)=>{const s=t.channel(e.channelName);n(s,i).authorize(e.socketId,r)}})(e,n,t.authorizer))),n}(t,e);return"customHandler"in n&&null!=n.customHandler?n.customHandler:ye(n)}class Pe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on watchlist events for "+t)})),this.pusher=t,this.bindWatchlistInternalEvent()}handleEvent(t){t.data.events.forEach(t=>{this.emit(t.name,t)})}bindWatchlistInternalEvent(){this.pusher.connection.bind("message",t=>{"pusher_internal:watchlist_events"===t.event&&this.handleEvent(t)})}}var Ee=function(){let t,e;return{promise:new Promise((n,i)=>{t=n,e=i}),resolve:t,reject:e}};class Oe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on user for "+t)})),this.signin_requested=!1,this.user_data=null,this.serverToUserChannel=null,this.signinDonePromise=null,this._signinDoneResolve=null,this._onAuthorize=(t,e)=>{if(t)return V.warn("Error during signin: "+t),void this._cleanup();this.pusher.send_event("pusher:signin",{auth:e.auth,user_data:e.user_data})},this.pusher=t,this.pusher.connection.bind("state_change",({previous:t,current:e})=>{"connected"!==t&&"connected"===e&&this._signin(),"connected"===t&&"connected"!==e&&(this._cleanup(),this._newSigninPromiseIfNeeded())}),this.watchlist=new Pe(t),this.pusher.connection.bind("message",t=>{"pusher:signin_success"===t.event&&this._onSigninSuccess(t.data),this.serverToUserChannel&&this.serverToUserChannel.name===t.channel&&this.serverToUserChannel.handleEvent(t)})}signin(){this.signin_requested||(this.signin_requested=!0,this._signin())}_signin(){this.signin_requested&&(this._newSigninPromiseIfNeeded(),"connected"===this.pusher.connection.state&&this.pusher.config.userAuthenticator({socketId:this.pusher.connection.socket_id},this._onAuthorize))}_onSigninSuccess(t){try{this.user_data=JSON.parse(t.user_data)}catch(e){return V.error("Failed parsing user data after signin: "+t.user_data),void this._cleanup()}if("string"!=typeof this.user_data.id||""===this.user_data.id)return V.error("user_data doesn't contain an id. user_data: "+this.user_data),void this._cleanup();this._signinDoneResolve(),this._subscribeChannels()}_subscribeChannels(){this.serverToUserChannel=new Ot("#server-to-user-"+this.user_data.id,this.pusher),this.serverToUserChannel.bind_global((t,e)=>{0!==t.indexOf("pusher_internal:")&&0!==t.indexOf("pusher:")&&this.emit(t,e)}),(t=>{t.subscriptionPending&&t.subscriptionCancelled?t.reinstateSubscription():t.subscriptionPending||"connected"!==this.pusher.connection.state||t.subscribe()})(this.serverToUserChannel)}_cleanup(){this.user_data=null,this.serverToUserChannel&&(this.serverToUserChannel.unbind_all(),this.serverToUserChannel.disconnect(),this.serverToUserChannel=null),this.signin_requested&&this._signinDoneResolve()}_newSigninPromiseIfNeeded(){if(!this.signin_requested)return;if(this.signinDonePromise&&!this.signinDonePromise.done)return;const{promise:t,resolve:e,reject:n}=Ee();t.done=!1;const i=()=>{t.done=!0};t.then(i).catch(i),this.signinDonePromise=t,this._signinDoneResolve=e}}class xe{static ready(){xe.isReady=!0;for(var t=0,e=xe.instances.length;tue.getDefaultStrategy(this.config,t,ve),timeline:this.timeline,activityTimeout:this.config.activityTimeout,pongTimeout:this.config.pongTimeout,unavailableTimeout:this.config.unavailableTimeout,useTLS:Boolean(this.config.useTLS)}),this.connection.bind("connected",()=>{this.subscribeAll(),this.timelineSender&&this.timelineSender.send(this.connection.isUsingTLS())}),this.connection.bind("message",t=>{var e=0===t.event.indexOf("pusher_internal:");if(t.channel){var n=this.channel(t.channel);n&&n.handleEvent(t)}e||this.global_emitter.emit(t.event,t.data)}),this.connection.bind("connecting",()=>{this.channels.disconnect()}),this.connection.bind("disconnected",()=>{this.channels.disconnect()}),this.connection.bind("error",t=>{V.warn(t)}),xe.instances.push(this),this.timeline.info({instances:xe.instances.length}),this.user=new Oe(this),xe.isReady&&this.connect()}channel(t){return this.channels.find(t)}allChannels(){return this.channels.all()}connect(){if(this.connection.connect(),this.timelineSender&&!this.timelineSenderTimer){var t=this.connection.isUsingTLS(),e=this.timelineSender;this.timelineSenderTimer=new D(6e4,(function(){e.send(t)}))}}disconnect(){this.connection.disconnect(),this.timelineSenderTimer&&(this.timelineSenderTimer.ensureAborted(),this.timelineSenderTimer=null)}bind(t,e,n){return this.global_emitter.bind(t,e,n),this}unbind(t,e,n){return this.global_emitter.unbind(t,e,n),this}bind_global(t){return this.global_emitter.bind_global(t),this}unbind_global(t){return this.global_emitter.unbind_global(t),this}unbind_all(t){return this.global_emitter.unbind_all(),this}subscribeAll(){var t;for(t in this.channels.channels)this.channels.channels.hasOwnProperty(t)&&this.subscribe(t)}subscribe(t){var e=this.channels.add(t,this);return e.subscriptionPending&&e.subscriptionCancelled?e.reinstateSubscription():e.subscriptionPending||"connected"!==this.connection.state||e.subscribe(),e}unsubscribe(t){var e=this.channels.find(t);e&&e.subscriptionPending?e.cancelSubscription():(e=this.channels.remove(t))&&e.subscribed&&e.unsubscribe()}send_event(t,e,n){return this.connection.send_event(t,e,n)}shouldUseTLS(){return this.config.useTLS}signin(){this.user.signin()}}xe.instances=[],xe.isReady=!1,xe.logToConsole=!1,xe.Runtime=ue,xe.ScriptReceivers=ue.ScriptReceivers,xe.DependenciesReceivers=ue.DependenciesReceivers,xe.auth_callbacks=ue.auth_callbacks;var Le=e.default=xe;ue.setup(xe)}])})); //# sourceMappingURL=pusher.min.js.map diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 8ef2c3f51..1eed3d486 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -9,8 +9,11 @@ fullscreen: @entangle('fullscreen'), alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, rafId: null, + scrollTimeout: null, scrollDebounce: null, isScrolling: false, + destroyed: false, + deploymentFinishedCleanup: null, lastTouchY: 0, showTimestamps: true, searchQuery: '', @@ -20,20 +23,28 @@ this.fullscreen = !this.fullscreen; }, scrollToBottom() { - const logsContainer = document.getElementById('logsContainer'); + if (this.destroyed) return; + const logsContainer = this.$root.querySelector('#logsContainer'); if (logsContainer) { this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; - setTimeout(() => { this.isScrolling = false; }, 50); + requestAnimationFrame(() => { this.isScrolling = false; }); + } + }, + cancelScrollLoop() { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + this.scrollTimeout = null; } }, disableFollow() { if (!this.alwaysScroll) return; this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + this.cancelScrollLoop(); }, handleWheel(event) { if (this.alwaysScroll && event.deltaY < 0) { @@ -59,10 +70,11 @@ } }, handleScroll(event) { - if (this.isScrolling) return; + if (this.isScrolling || this.destroyed) return; + const el = event.target; clearTimeout(this.scrollDebounce); this.scrollDebounce = setTimeout(() => { - const el = event.target; + if (this.destroyed) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (!this.alwaysScroll && distanceFromBottom <= 10) { this.alwaysScroll = true; @@ -71,11 +83,12 @@ }, 150); }, scheduleScroll() { - if (!this.alwaysScroll) return; + if (!this.alwaysScroll || this.destroyed) return; this.rafId = requestAnimationFrame(() => { + if (!this.alwaysScroll || this.destroyed) return; this.scrollToBottom(); - if (this.alwaysScroll) { - setTimeout(() => this.scheduleScroll(), 250); + if (this.alwaysScroll && !this.destroyed) { + this.scrollTimeout = setTimeout(() => this.scheduleScroll(), 250); } }); }, @@ -84,10 +97,7 @@ if (this.alwaysScroll) { this.scheduleScroll(); } else { - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + this.cancelScrollLoop(); } }, hasActiveLogSelection() { @@ -189,10 +199,7 @@ stopScroll() { this.scrollToBottom(); this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + this.cancelScrollLoop(); }, init() { // Watch search query changes @@ -200,21 +207,28 @@ this.applySearch(); }); - // Apply search after Livewire updates + // Apply search after Livewire updates. + // Livewire.hook() has no deregister API, so this callback survives + // wire:navigate. It is made harmless after teardown by the + // `destroyed` guard and by only reacting to DOM inside this root. Livewire.hook('morph.updated', ({ el }) => { - if (el.id === 'logs') { - this.$nextTick(() => { - this.applySearch(); - if (this.alwaysScroll) { - this.scrollToBottom(); - } - }); - } + if (this.destroyed) return; + if (el.id !== 'logs' || !this.$root.contains(el)) return; + this.$nextTick(() => { + if (this.destroyed) return; + this.applySearch(); + if (this.alwaysScroll) { + this.scrollToBottom(); + } + }); }); - // Stop auto-scroll when deployment finishes - Livewire.on('deploymentFinished', () => { + // Stop auto-scroll when deployment finishes. + // Livewire.on() returns an unregister fn; keep it for destroy(). + this.deploymentFinishedCleanup = Livewire.on('deploymentFinished', () => { + if (this.destroyed) return; setTimeout(() => { + if (this.destroyed) return; this.stopScroll(); }, 500); }); @@ -223,6 +237,20 @@ if (this.alwaysScroll) { this.scheduleScroll(); } + }, + destroy() { + // Runs when Alpine tears the component down (wire:navigate away). + this.destroyed = true; + this.alwaysScroll = false; + this.cancelScrollLoop(); + if (this.scrollDebounce) { + clearTimeout(this.scrollDebounce); + this.scrollDebounce = null; + } + if (typeof this.deploymentFinishedCleanup === 'function') { + this.deploymentFinishedCleanup(); + this.deploymentFinishedCleanup = null; + } } }" class="flex flex-1 min-h-0 flex-col overflow-hidden"> { const env = loadEnv(mode, process.cwd(), '') @@ -36,19 +35,6 @@ export default defineConfig(({ mode }) => { input: ["resources/css/app.css", "resources/js/app.js"], refresh: true, }), - vue({ - template: { - transformAssetUrls: { - base: null, - includeAbsolute: false, - }, - }, - }), ], - resolve: { - alias: { - vue: "vue/dist/vue.esm-bundler.js", - }, - }, } }); From 36526928df6c896749c97a8e7abb63ab06fe0b4a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 12:44:41 +0200 Subject: [PATCH 044/122] feat(sentinel): deduplicate metrics push processing Move Sentinel push handling into a controller and dispatch server update jobs only when container state changes or the force interval elapses. Add opt-in PostgreSQL read/write replica configuration and tune periodic proxy network and storage checks to reduce unnecessary work. Add feature coverage for replica config, Sentinel push deduplication, deployment log scrolling, and server update job optimizations. --- .env.development.example | 12 ++ .../Controllers/Api/SentinelController.php | 146 ++++++++++++++++++ app/Jobs/PushServerUpdateJob.php | 17 +- config/constants.php | 15 ++ config/database.php | 58 +++++-- routes/api.php | 73 +-------- tests/Feature/DatabaseReplicaConfigTest.php | 74 +++++++++ tests/Feature/DeploymentLogScrollTest.php | 99 ++++++++++++ .../PushServerUpdateJobOptimizationTest.php | 69 +++++++-- .../Feature/SentinelPushDeduplicationTest.php | 119 ++++++++++++++ 10 files changed, 577 insertions(+), 105 deletions(-) create mode 100644 app/Http/Controllers/Api/SentinelController.php create mode 100644 tests/Feature/DatabaseReplicaConfigTest.php create mode 100644 tests/Feature/DeploymentLogScrollTest.php create mode 100644 tests/Feature/SentinelPushDeduplicationTest.php diff --git a/.env.development.example b/.env.development.example index 594b89201..d02b8ba59 100644 --- a/.env.development.example +++ b/.env.development.example @@ -15,6 +15,18 @@ DB_PASSWORD=password DB_HOST=host.docker.internal DB_PORT=5432 +# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split. +# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset. +# DB_READ_HOST=replica1,replica2 +# DB_READ_PORT=5432 +# DB_READ_USERNAME=coolify +# DB_READ_PASSWORD= +# DB_WRITE_HOST= +# DB_WRITE_PORT=5432 +# DB_WRITE_USERNAME=coolify +# DB_WRITE_PASSWORD= +# DB_STICKY=true + # Ray Configuration # Set to true to enable Ray RAY_ENABLED=false diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php new file mode 100644 index 000000000..4a469f09c --- /dev/null +++ b/app/Http/Controllers/Api/SentinelController.php @@ -0,0 +1,146 @@ +header('Authorization'); + if (! $token) { + auditLogWebhookFailure('sentinel', 'token_missing'); + + return response()->json(['message' => 'Unauthorized'], 401); + } + $naked_token = str_replace('Bearer ', '', $token); + try { + $decrypted = decrypt($naked_token); + $decrypted_token = json_decode($decrypted, true); + } catch (Exception $e) { + auditLogWebhookFailure('sentinel', 'decrypt_failed'); + + return response()->json(['message' => 'Invalid token'], 401); + } + $server_uuid = data_get($decrypted_token, 'server_uuid'); + if (! $server_uuid) { + auditLogWebhookFailure('sentinel', 'invalid_token_payload'); + + return response()->json(['message' => 'Invalid token'], 401); + } + $server = Server::where('uuid', $server_uuid)->first(); + if (! $server) { + auditLogWebhookFailure('sentinel', 'server_not_found', [ + 'server_uuid' => $server_uuid, + ]); + + return response()->json(['message' => 'Server not found'], 404); + } + + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + auditLogWebhookFailure('sentinel', 'subscription_unpaid', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'Unauthorized'], 401); + } + + if ($server->isFunctional() === false) { + auditLogWebhookFailure('sentinel', 'server_not_functional', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'Server is not functional'], 401); + } + + if ($server->settings->sentinel_token !== $naked_token) { + auditLogWebhookFailure('sentinel', 'token_mismatch', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'Unauthorized'], 401); + } + $data = $request->all(); + + // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping. + $server->sentinelHeartbeat(); + + if ($this->shouldDispatchUpdate($server, $data)) { + PushServerUpdateJob::dispatch($server, $data); + } + + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'ok'], 200); + } + + /** + * Decide whether PushServerUpdateJob should be dispatched for this push. + * + * Dispatches when: first push (no cached hash), the container state changed, + * or the force window elapsed. + */ + private function shouldDispatchUpdate(Server $server, array $data): bool + { + $hash = $this->containerStateHash($data); + $hashKey = "sentinel:push-hash:{$server->id}"; + $forceKey = "sentinel:push-force:{$server->id}"; + + $cachedHash = Cache::get($hashKey); + $forceActive = Cache::has($forceKey); + + $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive; + + if ($shouldDispatch) { + // Day-long TTL bounds memory if a server stops pushing entirely. + Cache::put($hashKey, $hash, now()->addDay()); + Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300)); + } + + return $shouldDispatch; + } + + /** + * Build a stable hash of container state. + * + * Covers [name, state, health_status] only — metrics and + * filesystem_usage_root are excluded on purpose (disk % churns constantly + * and would defeat the hash; the storage check is separately cache-gated + * inside PushServerUpdateJob). Sorted by name so container ordering from + * Sentinel does not affect the hash. + */ + private function containerStateHash(array $data): string + { + $containers = collect(data_get($data, 'containers', [])) + ->map(fn ($c) => [ + 'name' => data_get($c, 'name'), + 'state' => data_get($c, 'state'), + 'health_status' => data_get($c, 'health_status'), + ]) + ->sortBy('name') + ->values() + ->all(); + + return hash('xxh128', json_encode($containers)); + } +} diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index b1a12ae2a..cdfa174ed 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -127,15 +127,20 @@ public function handle() } $data = collect($this->data); - $this->server->sentinelHeartbeat(); - + // Heartbeat is updated by SentinelController on every push, before dispatch. $this->containers = collect(data_get($data, 'containers')); $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - // Only dispatch storage check when disk percentage actually changes + // Only dispatch the storage check when disk usage is at/above the notification + // threshold AND the value changed. Below the threshold ServerStorageCheckJob + // has nothing to do (it only sends a HighDiskUsage notification), so dispatching + // it is wasted work — and most servers sit well below the threshold. + $diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80); $storageCacheKey = 'storage-check:'.$this->server->id; $lastPercentage = Cache::get($storageCacheKey); - if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) { + if ($filesystemUsageRoot !== null + && $filesystemUsageRoot >= $diskThreshold + && (string) $lastPercentage !== (string) $filesystemUsageRoot) { Cache::put($storageCacheKey, $filesystemUsageRoot, 600); ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); } @@ -500,11 +505,11 @@ private function updateProxyStatus() } catch (\Throwable $e) { } } else { - // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches. + // Connect proxy to networks periodically as a safety net to avoid excessive job dispatches. // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this. $proxyCacheKey = 'connect-proxy:'.$this->server->id; if (! Cache::has($proxyCacheKey)) { - Cache::put($proxyCacheKey, true, 600); + Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600)); ConnectProxyToNetworksJob::dispatch($this->server); } } diff --git a/config/constants.php b/config/constants.php index bd3e5b2aa..f4a32be6a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -94,6 +94,21 @@ 'sentry_dsn' => env('SENTRY_DSN'), ], + 'sentinel' => [ + // How often (seconds) PushServerUpdateJob is force-dispatched even when + // the container state hash is unchanged. Keeps last_online_at, + // exited-detection and storage checks from going stale. + 'push_force_interval_seconds' => env('SENTINEL_PUSH_FORCE_INTERVAL_SECONDS', 300), + ], + + 'proxy' => [ + // How often (seconds) PushServerUpdateJob periodically re-connects the + // proxy to Docker networks as a safety net. Real network-layout changes + // already connect the proxy on-demand; this only covers gaps (Swarm + // networks added via UI, proxy crash recovery). + 'connect_networks_interval_seconds' => env('PROXY_CONNECT_NETWORKS_INTERVAL_SECONDS', 3600), + ], + 'webhooks' => [ 'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'), 'dev_webhook' => env('SERVEO_URL'), diff --git a/config/database.php b/config/database.php index a5e0ba703..94c27f038 100644 --- a/config/database.php +++ b/config/database.php @@ -1,6 +1,46 @@ 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', 'coolify-db'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'coolify'), + 'username' => env('DB_USERNAME', 'coolify'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + 'options' => [ + (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), + ], +]; + +/* + * Opt-in read/write replica split. Activates only when DB_READ_HOST is set. + * When unset, the pgsql connection is identical to a single-primary setup. + * Hosts may be comma-separated; Laravel random-picks one per connection. + */ +if (env('DB_READ_HOST')) { + $pgsql['read'] = [ + 'host' => array_map('trim', explode(',', (string) env('DB_READ_HOST'))), + 'port' => env('DB_READ_PORT', env('DB_PORT', '5432')), + 'username' => env('DB_READ_USERNAME', env('DB_USERNAME', 'coolify')), + 'password' => env('DB_READ_PASSWORD', env('DB_PASSWORD', '')), + ]; + $pgsql['write'] = [ + 'host' => array_map('trim', explode(',', (string) env('DB_WRITE_HOST', env('DB_HOST', 'coolify-db')))), + 'port' => env('DB_WRITE_PORT', env('DB_PORT', '5432')), + 'username' => env('DB_WRITE_USERNAME', env('DB_USERNAME', 'coolify')), + 'password' => env('DB_WRITE_PASSWORD', env('DB_PASSWORD', '')), + ]; + $pgsql['sticky'] = (bool) env('DB_STICKY', true); +} return [ @@ -35,23 +75,7 @@ 'connections' => [ - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', 'coolify-db'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'coolify'), - 'username' => env('DB_USERNAME', 'coolify'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => 'prefer', - 'options' => [ - (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), - ], - ], + 'pgsql' => $pgsql, 'testing' => [ 'driver' => 'sqlite', diff --git a/routes/api.php b/routes/api.php index cc380b2be..fb3b4bad6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,12 +11,11 @@ use App\Http\Controllers\Api\ResourcesController; use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; +use App\Http\Controllers\Api\SentinelController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TeamController; use App\Http\Middleware\ApiAllowed; -use App\Jobs\PushServerUpdateJob; -use App\Models\Server; use Illuminate\Support\Facades\Route; Route::get('/health', [OtherController::class, 'healthcheck']); @@ -209,75 +208,7 @@ Route::group([ 'prefix' => 'v1', ], function () { - Route::post('/sentinel/push', function () { - $token = request()->header('Authorization'); - if (! $token) { - auditLogWebhookFailure('sentinel', 'token_missing'); - - return response()->json(['message' => 'Unauthorized'], 401); - } - $naked_token = str_replace('Bearer ', '', $token); - try { - $decrypted = decrypt($naked_token); - $decrypted_token = json_decode($decrypted, true); - } catch (Exception $e) { - auditLogWebhookFailure('sentinel', 'decrypt_failed'); - - return response()->json(['message' => 'Invalid token'], 401); - } - $server_uuid = data_get($decrypted_token, 'server_uuid'); - if (! $server_uuid) { - auditLogWebhookFailure('sentinel', 'invalid_token_payload'); - - return response()->json(['message' => 'Invalid token'], 401); - } - $server = Server::where('uuid', $server_uuid)->first(); - if (! $server) { - auditLogWebhookFailure('sentinel', 'server_not_found', [ - 'server_uuid' => $server_uuid, - ]); - - return response()->json(['message' => 'Server not found'], 404); - } - - if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - auditLogWebhookFailure('sentinel', 'subscription_unpaid', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); - - return response()->json(['message' => 'Unauthorized'], 401); - } - - if ($server->isFunctional() === false) { - auditLogWebhookFailure('sentinel', 'server_not_functional', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); - - return response()->json(['message' => 'Server is not functional'], 401); - } - - if ($server->settings->sentinel_token !== $naked_token) { - auditLogWebhookFailure('sentinel', 'token_mismatch', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); - - return response()->json(['message' => 'Unauthorized'], 401); - } - $data = request()->all(); - - // \App\Jobs\ServerCheckNewJob::dispatch($server, $data); - PushServerUpdateJob::dispatch($server, $data); - - auditLog('sentinel.metrics_pushed', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); - - return response()->json(['message' => 'ok'], 200); - }); + Route::post('/sentinel/push', [SentinelController::class, 'push']); }); Route::any('/{any}', function () { diff --git a/tests/Feature/DatabaseReplicaConfigTest.php b/tests/Feature/DatabaseReplicaConfigTest.php new file mode 100644 index 000000000..8a9c58a38 --- /dev/null +++ b/tests/Feature/DatabaseReplicaConfigTest.php @@ -0,0 +1,74 @@ +not->toHaveKey('read') + ->not->toHaveKey('write') + ->not->toHaveKey('sticky') + ->and($pgsql['driver'])->toBe('pgsql'); +}); + +it('enables the read/write split when DB_READ_HOST is set', function () { + putenv('DB_READ_HOST=replica1, replica2'); + + $pgsql = loadDbConfig()['connections']['pgsql']; + + expect($pgsql) + ->toHaveKey('read') + ->toHaveKey('write') + ->and($pgsql['read']['host'])->toBe(['replica1', 'replica2']) + ->and($pgsql['sticky'])->toBeTrue(); +}); + +it('falls back to DB_* values for unset replica options', function () { + putenv('DB_READ_HOST=replica1'); + + $pgsql = loadDbConfig()['connections']['pgsql']; + + expect($pgsql['read']['port'])->toBe(env('DB_PORT', '5432')) + ->and($pgsql['read']['username'])->toBe(env('DB_USERNAME', 'coolify')) + ->and($pgsql['write']['host'])->toBe([env('DB_HOST', 'coolify-db')]); +}); + +it('respects discrete replica overrides', function () { + putenv('DB_READ_HOST=replica1'); + putenv('DB_READ_PORT=6432'); + putenv('DB_READ_USERNAME=reader'); + + $pgsql = loadDbConfig()['connections']['pgsql']; + + expect($pgsql['read']['port'])->toBe('6432') + ->and($pgsql['read']['username'])->toBe('reader'); +}); + +it('disables sticky reads when DB_STICKY is false', function () { + putenv('DB_READ_HOST=replica1'); + putenv('DB_STICKY=false'); + + $pgsql = loadDbConfig()['connections']['pgsql']; + + expect($pgsql['sticky'])->toBeFalse(); +}); diff --git a/tests/Feature/DeploymentLogScrollTest.php b/tests/Feature/DeploymentLogScrollTest.php new file mode 100644 index 000000000..c40752542 --- /dev/null +++ b/tests/Feature/DeploymentLogScrollTest.php @@ -0,0 +1,99 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + InstanceSettings::unguarded(function () { + InstanceSettings::query()->create([ + 'id' => 0, + 'is_registration_enabled' => true, + ]); + }); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); +}); + +function showDeployment(string $status): TestResponse +{ + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => test()->application->id, + 'deployment_uuid' => 'deploy-scroll-'.$status, + 'server_id' => test()->server->id, + 'status' => $status, + 'logs' => json_encode([[ + 'command' => null, + 'output' => 'log line for '.$status, + 'type' => 'stdout', + 'timestamp' => now()->toISOString(), + 'hidden' => false, + 'batch' => 1, + 'order' => 1, + ]], JSON_THROW_ON_ERROR), + ]); + + return test()->get(route('project.application.deployment.show', [ + 'project_uuid' => test()->project->uuid, + 'environment_uuid' => test()->environment->uuid, + 'application_uuid' => test()->application->uuid, + 'deployment_uuid' => $deployment->deployment_uuid, + ])); +} + +it('does not enable follow mode for a finished deployment', function () { + $response = showDeployment(ApplicationDeploymentStatus::FINISHED->value); + + $response->assertSuccessful(); + $response->assertSee('alwaysScroll: false', false); + $response->assertDontSee('alwaysScroll: true', false); +}); + +it('enables follow mode for an in-progress deployment', function () { + $response = showDeployment(ApplicationDeploymentStatus::IN_PROGRESS->value); + + $response->assertSuccessful(); + $response->assertSee('alwaysScroll: true', false); +}); + +it('scopes scroll teardown to the component so a stale loop cannot leak across deployments', function () { + $content = showDeployment(ApplicationDeploymentStatus::FINISHED->value)->getContent(); + + // Alpine destroy() tears the scroll loop down on wire:navigate away. + expect($content)->toContain('destroy()') + ->toContain('cancelScrollLoop()') + // Container lookup is component-scoped, not a global getElementById. + ->toContain("this.\$root.querySelector('#logsContainer')") + ->not->toContain("document.getElementById('logsContainer')") + // morph.updated hook only acts on this component's own DOM. + ->toContain('this.$root.contains(el)') + // Continuation timeout is tracked so it can be cancelled. + ->toContain('scrollTimeout'); +}); diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php index eb51059db..92c98a2e1 100644 --- a/tests/Feature/PushServerUpdateJobOptimizationTest.php +++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php @@ -16,10 +16,29 @@ Cache::flush(); }); -it('dispatches storage check when disk percentage changes', function () { +it('dispatches storage check when disk percentage changes above threshold', function () { $team = Team::factory()->create(); $server = Server::factory()->create(['team_id' => $team->id]); + // Default notification threshold is 80%. + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 85], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 85; + }); +}); + +it('does not dispatch storage check when disk usage is below threshold', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // 45% is well below the default 80% notification threshold — nothing to do. $data = [ 'containers' => [], 'filesystem_usage_root' => ['used_percentage' => 45], @@ -28,21 +47,19 @@ $job = new PushServerUpdateJob($server, $data); $job->handle(); - Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { - return $job->server->id === $server->id && $job->percentage === 45; - }); + Queue::assertNotPushed(ServerStorageCheckJob::class); }); it('does not dispatch storage check when disk percentage is unchanged', function () { $team = Team::factory()->create(); $server = Server::factory()->create(['team_id' => $team->id]); - // Simulate a previous push that cached the percentage - Cache::put('storage-check:'.$server->id, 45, 600); + // Simulate a previous push that cached the percentage (above threshold). + Cache::put('storage-check:'.$server->id, 85, 600); $data = [ 'containers' => [], - 'filesystem_usage_root' => ['used_percentage' => 45], + 'filesystem_usage_root' => ['used_percentage' => 85], ]; $job = new PushServerUpdateJob($server, $data); @@ -55,19 +72,19 @@ $team = Team::factory()->create(); $server = Server::factory()->create(['team_id' => $team->id]); - // Simulate a previous push that cached 45% - Cache::put('storage-check:'.$server->id, 45, 600); + // Simulate a previous push that cached 85% (above threshold). + Cache::put('storage-check:'.$server->id, 85, 600); $data = [ 'containers' => [], - 'filesystem_usage_root' => ['used_percentage' => 50], + 'filesystem_usage_root' => ['used_percentage' => 90], ]; $job = new PushServerUpdateJob($server, $data); $job->handle(); Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { - return $job->server->id === $server->id && $job->percentage === 50; + return $job->server->id === $server->id && $job->percentage === 90; }); }); @@ -140,6 +157,36 @@ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); }); +it('respects the configured proxy connect interval', function () { + // Interval 0 → the connect-proxy gate key expires immediately, so every + // push re-dispatches without a manual Cache::forget. Proves the TTL is + // driven by config('constants.proxy.connect_networks_interval_seconds'). + config(['constants.proxy.connect_networks_interval_seconds' => 0]); + + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $data = [ + 'containers' => [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ], + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + (new PushServerUpdateJob($server, $data))->handle(); + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + Queue::fake(); + (new PushServerUpdateJob($server, $data))->handle(); + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); +}); + it('uses default queue for PushServerUpdateJob', function () { $team = Team::factory()->create(); $server = Server::factory()->create(['team_id' => $team->id]); diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php new file mode 100644 index 000000000..e5995a687 --- /dev/null +++ b/tests/Feature/SentinelPushDeduplicationTest.php @@ -0,0 +1,119 @@ +create(); + $this->team = $user->teams()->first(); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $this->token = $this->server->settings->sentinel_token; +}); + +function pushSentinel(string $token, array $payload) +{ + return test()->postJson('/api/v1/sentinel/push', $payload, [ + 'Authorization' => 'Bearer '.$token, + ]); +} + +function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): array +{ + return [ + 'containers' => $containers, + 'filesystem_usage_root' => ['used_percentage' => $diskPercentage], + ]; +} + +$running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']]; + +it('dispatches the job on the first push', function () use ($running) { + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 1); +}); + +it('skips the job when the second push is identical', function () use ($running) { + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 1); +}); + +it('updates the heartbeat even when the job is skipped', function () use ($running) { + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + $this->server->update(['sentinel_updated_at' => now()->subHour()]); + + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 1); + expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5); +}); + +it('dispatches the job when container state changes', function () use ($running) { + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + $exited = [['name' => 'app-1', 'state' => 'exited', 'health_status' => 'unhealthy']]; + pushSentinel($this->token, sentinelPayload($exited))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 2); +}); + +it('ignores disk percentage changes (excluded from the hash)', function () use ($running) { + pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk(); + pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 1); +}); + +it('ignores container reordering (hash is sorted by name)', function () { + $order1 = [ + ['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'], + ['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'], + ]; + $order2 = [ + ['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'], + ['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'], + ]; + + pushSentinel($this->token, sentinelPayload($order1))->assertOk(); + pushSentinel($this->token, sentinelPayload($order2))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 1); +}); + +it('force-dispatches an identical push after the force window expires', function () use ($running) { + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + // Simulate the force key TTL elapsing. + Cache::forget('sentinel:push-force:'.$this->server->id); + + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 2); +}); + +it('rejects an invalid token without dispatching', function () use ($running) { + pushSentinel('not-a-real-token', sentinelPayload($running()))->assertUnauthorized(); + + Queue::assertNotPushed(PushServerUpdateJob::class); +}); From 59111e8cf35824b691790cc018d76d2c5a331793 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 12:53:14 +0200 Subject: [PATCH 045/122] fix(destination): scope server and network selection to current team Resolve the server and network in Destination::addServer() and ::promote() through ownedByCurrentTeam() before use, authorize the update against the resource, and pass the validated IDs into attach()/detach()/update(). Errors are routed through handleError() to match the sibling removeServer() method. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Livewire/Project/Shared/Destination.php | 38 ++++-- .../CrossTeamDestinationAttachTest.php | 124 ++++++++++++++++++ 2 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/CrossTeamDestinationAttachTest.php diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 363471760..d9e560074 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -110,15 +110,23 @@ public function redeploy(int $network_id, int $server_id) public function promote(int $network_id, int $server_id) { - $main_destination = $this->resource->destination; - $this->resource->update([ - 'destination_id' => $network_id, - 'destination_type' => StandaloneDocker::class, - ]); - $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); - $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); - $this->refreshServers(); - $this->resource->refresh(); + try { + $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_id); + $this->authorize('update', $this->resource); + + $main_destination = $this->resource->destination; + $this->resource->update([ + 'destination_id' => $network->id, + 'destination_type' => StandaloneDocker::class, + ]); + $this->resource->additional_networks()->detach($network->id, ['server_id' => $server->id]); + $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); + $this->refreshServers(); + $this->resource->refresh(); + } catch (\Exception $e) { + return handleError($e, $this); + } } public function refreshServers() @@ -130,8 +138,16 @@ public function refreshServers() public function addServer(int $network_id, int $server_id) { - $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); - $this->dispatch('refresh'); + try { + $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_id); + $this->authorize('update', $this->resource); + + $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]); + $this->dispatch('refresh'); + } catch (\Exception $e) { + return handleError($e, $this); + } } public function removeServer(int $network_id, int $server_id, $password, $selectedActions = []) diff --git a/tests/Feature/CrossTeamDestinationAttachTest.php b/tests/Feature/CrossTeamDestinationAttachTest.php new file mode 100644 index 000000000..74c287107 --- /dev/null +++ b/tests/Feature/CrossTeamDestinationAttachTest.php @@ -0,0 +1,124 @@ + InstanceSettings::query()->create(['id' => 0])); + + // Attacker: Team A + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->destinationA = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA->id, + 'name' => 'dest-a-'.fake()->unique()->word(), + 'network' => 'coolify-a-'.fake()->unique()->word(), + ]); + + $this->applicationA = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ]); + + // A second usable destination on Team A's own server, used for positive-path tests. + $this->serverA2 = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA2 = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA2->id, + 'name' => 'dest-a2-'.fake()->unique()->word(), + 'network' => 'coolify-a2-'.fake()->unique()->word(), + ]); + + // Victim: Team B + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverB->id, + 'name' => 'dest-b-'.fake()->unique()->word(), + 'network' => 'coolify-b-'.fake()->unique()->word(), + ]); + + // Act as attacker (Team A) + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () { + test('cannot attach another team\'s server + network to own application', function () { + try { + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('addServer', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + // handleError on ModelNotFoundException calls abort(404); pivot assertion is source of truth. + } + + expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0); + expect($this->applicationA->fresh()->additional_servers)->toHaveCount(0); + }); + + test('cannot attach own network paired with another team\'s server', function () { + try { + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('addServer', $this->destinationA2->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0); + }); + + test('cannot attach another team\'s network paired with own server', function () { + try { + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('addServer', $this->destinationB->id, $this->serverA2->id); + } catch (Throwable $e) { + } + + expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0); + }); + + test('can attach own team\'s server + network to own application', function () { + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('addServer', $this->destinationA2->id, $this->serverA2->id); + + $additional = $this->applicationA->fresh()->additional_networks; + expect($additional)->toHaveCount(1); + expect($additional->first()->id)->toBe($this->destinationA2->id); + expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id); + }); +}); + +describe('Destination::promote GHSA-j395-3pqh-9r5g', function () { + test('cannot promote another team\'s network as the application\'s main destination', function () { + $originalDestinationId = $this->applicationA->destination_id; + + try { + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('promote', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId); + }); +}); From e9b8320d5f2eb40176489a3a9fe0a7975a8460e2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 13:00:53 +0200 Subject: [PATCH 046/122] Fix source selection flow --- .../Project/New/GithubPrivateRepository.php | 26 ++++-- app/Models/GithubApp.php | 20 ----- tests/Feature/GithubPrivateRepositoryTest.php | 84 +++++++++++++++++++ 3 files changed, 101 insertions(+), 29 deletions(-) diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 0ce1bd1a2..c292e254c 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -9,6 +9,7 @@ use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; +use Livewire\Attributes\Locked; use Livewire\Component; class GithubPrivateRepository extends Component @@ -29,6 +30,7 @@ class GithubPrivateRepository extends Component public int $selected_repository_id; + #[Locked] public int $selected_github_app_id; public string $selected_repository_owner; @@ -37,8 +39,6 @@ class GithubPrivateRepository extends Component public string $selected_branch_name = 'main'; - public string $token; - public $repositories; public int $total_repositories_count = 0; @@ -71,7 +71,10 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->repositories = $this->branches = collect(); - $this->github_apps = GithubApp::private(); + $this->github_apps = GithubApp::where('team_id', currentTeam()->id) + ->where('is_public', false) + ->whereNotNull('app_id') + ->get(); } public function updatedSelectedRepositoryId(): void @@ -96,22 +99,25 @@ public function updatedBuildPack() } } - public function loadRepositories($github_app_id) + public function loadRepositories(int $github_app_id): void { $this->repositories = collect(); $this->branches = collect(); $this->total_branches_count = 0; $this->page = 1; $this->selected_github_app_id = $github_app_id; - $this->github_app = GithubApp::where('id', $github_app_id)->first(); - $this->token = generateGithubInstallationToken($this->github_app); - $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); + $this->github_app = GithubApp::where('team_id', currentTeam()->id) + ->where('is_public', false) + ->whereNotNull('app_id') + ->findOrFail($github_app_id); + $token = generateGithubInstallationToken($this->github_app); + $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); $this->total_repositories_count = $repositories['total_count']; $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); if ($this->repositories->count() < $this->total_repositories_count) { while ($this->repositories->count() < $this->total_repositories_count) { $this->page++; - $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); + $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); $this->total_repositories_count = $repositories['total_count']; $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); } @@ -142,7 +148,9 @@ public function loadBranches() protected function loadBranchByPage() { - $response = Http::GitHub($this->github_app->api_url, $this->token) + $token = generateGithubInstallationToken($this->github_app); + + $response = Http::GitHub($this->github_app->api_url, $token) ->timeout(20) ->retry(3, 200, throw: false) ->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [ diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 54bbb3f7d..e5032d2d0 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -73,26 +73,6 @@ public static function ownedByCurrentTeam() }); } - public static function public() - { - return GithubApp::where(function ($query) { - $query->where(function ($q) { - $q->where('team_id', currentTeam()->id) - ->orWhere('is_system_wide', true); - })->where('is_public', true); - })->whereNotNull('app_id')->get(); - } - - public static function private() - { - return GithubApp::where(function ($query) { - $query->where(function ($q) { - $q->where('team_id', currentTeam()->id) - ->orWhere('is_system_wide', true); - })->where('is_public', false); - })->whereNotNull('app_id')->get(); - } - public function team() { return $this->belongsTo(Team::class); diff --git a/tests/Feature/GithubPrivateRepositoryTest.php b/tests/Feature/GithubPrivateRepositoryTest.php index ba66a10bb..5da17065d 100644 --- a/tests/Feature/GithubPrivateRepositoryTest.php +++ b/tests/Feature/GithubPrivateRepositoryTest.php @@ -5,8 +5,10 @@ use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -64,6 +66,21 @@ function fakeGithubHttp(array $repositories): void ]); } +function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey +{ + $rsaKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($rsaKey, $pemKey); + + return PrivateKey::create([ + 'name' => 'Test Key '.$team->id, + 'private_key' => $pemKey, + 'team_id' => $team->id, + ]); +} + describe('GitHub Private Repository Component', function () { test('loadRepositories fetches and displays repositories', function () { $repos = [ @@ -81,6 +98,73 @@ function fakeGithubHttp(array $repositories): void ->assertSet('selected_repository_id', 1); }); + test('loadRepositories rejects a github app owned by another team', function () { + $victimTeam = Team::factory()->create(); + $victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam); + $victimGithubApp = GithubApp::create([ + 'name' => 'Victim GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 54321, + 'installation_id' => 98765, + 'client_id' => 'victim-client-id', + 'client_secret' => 'victim-client-secret', + 'webhook_secret' => 'victim-webhook-secret', + 'private_key_id' => $victimPrivateKey->id, + 'team_id' => $victimTeam->id, + 'is_public' => false, + 'is_system_wide' => false, + ]); + + Http::fake(); + + expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->call('loadRepositories', $victimGithubApp->id) + )->toThrow(ModelNotFoundException::class); + + Http::assertNothingSent(); + }); + + test('loadRepositories does not mint tokens for another teams system wide github app', function () { + $victimTeam = Team::factory()->create(); + $victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam); + $systemWideGithubApp = GithubApp::create([ + 'name' => 'System Wide GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 54321, + 'installation_id' => 98765, + 'client_id' => 'system-client-id', + 'client_secret' => 'system-client-secret', + 'webhook_secret' => 'system-webhook-secret', + 'private_key_id' => $victimPrivateKey->id, + 'team_id' => $victimTeam->id, + 'is_public' => false, + 'is_system_wide' => true, + ]); + + Http::fake(); + + expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->call('loadRepositories', $systemWideGithubApp->id) + )->toThrow(ModelNotFoundException::class); + + Http::assertNothingSent(); + }); + + test('github installation token is not stored as public component state', function () { + expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse(); + }); + + test('selected github app id cannot be tampered with from the client', function () { + Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->set('selected_github_app_id', $this->githubApp->id); + })->throws(CannotUpdateLockedPropertyException::class); + test('loadRepositories can be called again to refresh the repository list', function () { $initialRepos = [ ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']], From 7f135e0f6d87a6065a67b78b8a9976dfd99f3a2a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 13:12:17 +0200 Subject: [PATCH 047/122] Harden token permission handling --- app/Livewire/Security/ApiTokens.php | 13 ++- .../ApiTokenLivewireAuthorizationTest.php | 97 +++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/ApiTokenLivewireAuthorizationTest.php diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 37d5332f3..c275ec097 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -5,6 +5,7 @@ use App\Models\InstanceSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Laravel\Sanctum\PersonalAccessToken; +use Livewire\Attributes\Locked; use Livewire\Component; class ApiTokens extends Component @@ -29,8 +30,10 @@ class ApiTokens extends Component public $isApiEnabled; + #[Locked] public bool $canUseRootPermissions = false; + #[Locked] public bool $canUseWritePermissions = false; public function render() @@ -54,7 +57,7 @@ private function getTokens() public function updatedPermissions($permissionToUpdate) { // Check if user is trying to use restricted permissions - if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) { + if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use root permissions.'); // Remove root from permissions if it was somehow added $this->permissions = array_diff($this->permissions, ['root']); @@ -62,7 +65,7 @@ public function updatedPermissions($permissionToUpdate) return; } - if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) { + if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use write permissions.'); // Remove write permissions if they were somehow added $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); @@ -72,7 +75,7 @@ public function updatedPermissions($permissionToUpdate) if ($permissionToUpdate == 'root') { $this->permissions = ['root']; - } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { + } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) { $this->permissions[] = 'read'; } elseif ($permissionToUpdate == 'deploy') { $this->permissions = ['deploy']; @@ -90,11 +93,11 @@ public function addNewToken() $this->authorize('create', PersonalAccessToken::class); // Validate permissions based on user role - if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) { + if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with root permissions.'); } - if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) { + if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with write permissions.'); } diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php new file mode 100644 index 000000000..e81b55aab --- /dev/null +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -0,0 +1,97 @@ + InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + + $this->team = Team::factory()->create(); +}); + +test('api token permission flags are locked', function (string $property) { + $property = new ReflectionProperty(ApiTokens::class, $property); + + expect($property->getAttributes(Locked::class))->not->toBeEmpty(); +})->with([ + 'root permission flag' => 'canUseRootPermissions', + 'write permission flag' => 'canUseWritePermissions', +]); + +test('member cannot tamper with root permission flag', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('canUseRootPermissions', true); +})->throws(CannotUpdateLockedPropertyException::class); + +test('member cannot create root token through tampered permissions payload', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'pwned-root-token') + ->set('expiresInDays', 30) + ->set('permissions', ['root']) + ->call('addNewToken'); + + expect($member->tokens()->count())->toBe(0); +}); + +test('member can still create read token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'read-token') + ->set('expiresInDays', 30) + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasNoErrors(); + + $token = $member->tokens()->latest()->first(); + + expect($token)->not->toBeNull() + ->and($token->abilities)->toBe(['read']); +}); + +test('owner can create root token', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'root-token') + ->set('expiresInDays', 30) + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertHasNoErrors(); + + $token = $owner->tokens()->latest()->first(); + + expect($token)->not->toBeNull() + ->and($token->abilities)->toBe(['root']); +}); From beaad0a722a8f944c8269422940b6c67c9cf2ab1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 13:38:28 +0200 Subject: [PATCH 048/122] Refine service resource routing --- app/Livewire/Project/Database/Import.php | 55 ++++--- .../Project/Service/DatabaseBackups.php | 14 +- app/Livewire/Project/Service/Heading.php | 30 ++++ app/Livewire/Project/Service/Index.php | 14 +- tests/Feature/ServiceResourceRoutingTest.php | 141 ++++++++++++++++++ 5 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/ServiceResourceRoutingTest.php diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 1cdc681cd..0fddce274 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -5,6 +5,15 @@ use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -219,7 +228,7 @@ public function updatedDumpAll($value) $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -231,7 +240,7 @@ public function updatedDumpAll($value) } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': if ($value === true) { $this->mariadbRestoreCommand = <<<'EOD' @@ -247,7 +256,7 @@ public function updatedDumpAll($value) $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': if ($value === true) { $this->mysqlRestoreCommand = <<<'EOD' @@ -263,7 +272,7 @@ public function updatedDumpAll($value) $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': if ($value === true) { $this->postgresqlRestoreCommand = <<<'EOD' @@ -299,10 +308,16 @@ public function getContainers() } elseif ($stackServiceUuid) { // ServiceDatabase route - look up the service database $serviceUuid = data_get($this->parameters, 'service_uuid'); - $service = Service::whereUuid($serviceUuid)->first(); - if (! $service) { - abort(404); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($this->parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($this->parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); if (is_null($resource)) { abort(404); @@ -321,7 +336,7 @@ public function getContainers() $this->resourceStatus = $resource->status ?? ''; // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $server = $resource->service?->server; if (! $server) { abort(404, 'Server not found for this service database.'); @@ -359,16 +374,16 @@ public function getContainers() } if ( - $resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class ) { $this->unsupported = true; } // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $dbType = $resource->databaseType(); if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { @@ -664,7 +679,7 @@ public function restoreFromS3(string $password = ''): bool|string $fullImageName = "{$helperImage}:{$latestVersion}"; // Get the database destination network - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->resource->getMorphClass() === ServiceDatabase::class) { $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; } else { $destinationNetwork = $this->resource->destination->network ?? 'coolify'; @@ -756,7 +771,7 @@ public function buildRestoreCommand(string $tmpPath): string $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -770,7 +785,7 @@ public function buildRestoreCommand(string $tmpPath): string } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': $restoreCommand = $this->mariadbRestoreCommand; if ($this->dumpAll) { @@ -779,7 +794,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': $restoreCommand = $this->mysqlRestoreCommand; if ($this->dumpAll) { @@ -788,7 +803,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { @@ -797,7 +812,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " {$tmpPath}"; } break; - case \App\Models\StandaloneMongodb::class: + case StandaloneMongodb::class: case 'mongodb': $restoreCommand = $this->mongodbRestoreCommand; if ($this->dumpAll === false) { diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 826a6c1ff..883441ecb 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -28,10 +28,16 @@ public function mount() try { $this->parameters = get_route_parameters(); $this->query = request()->query(); - $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); - if (! $this->service) { - return redirect()->route('dashboard'); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', $this->parameters['project_uuid']) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', $this->parameters['environment_uuid']) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index c8a08d8f9..60273ab23 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -7,12 +7,15 @@ use App\Actions\Service\StopService; use App\Enums\ProcessStatus; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; use Spatie\Activitylog\Models\Activity; class Heading extends Component { + use AuthorizesRequests; + public Service $service; public array $parameters; @@ -27,6 +30,8 @@ class Heading extends Component public function mount() { + $this->authorizeService('view'); + if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -47,6 +52,8 @@ public function getListeners() public function checkStatus() { + $this->authorizeService('view'); + if ($this->service->server->isFunctional()) { GetContainersStatus::dispatch($this->service->server); } else { @@ -61,6 +68,8 @@ public function manualCheckStatus() public function serviceChecked() { + $this->authorizeService('view'); + try { $this->service->applications->each(function ($application) { $application->refresh(); @@ -82,6 +91,8 @@ public function serviceChecked() public function checkDeployments() { + $this->authorizeService('view'); + try { $activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first(); $status = data_get($activity, 'properties.status'); @@ -99,12 +110,16 @@ public function checkDeployments() public function start() { + $this->authorizeService('deploy'); + $activity = StartService::run($this->service, pullLatestImages: true); $this->dispatch('activityMonitor', $activity->id); } public function forceDeploy() { + $this->authorizeService('deploy'); + try { $activities = Activity::where('properties->type_uuid', $this->service->uuid) ->where(function ($q) { @@ -124,6 +139,8 @@ public function forceDeploy() public function stop() { + $this->authorizeService('stop'); + try { StopService::dispatch($this->service, false, $this->docker_cleanup); } catch (\Exception $e) { @@ -133,6 +150,8 @@ public function stop() public function restart() { + $this->authorizeService('deploy'); + $this->checkDeployments(); if ($this->isDeploymentProgress) { $this->dispatch('error', 'There is a deployment in progress.'); @@ -145,6 +164,8 @@ public function restart() public function pullAndRestartEvent() { + $this->authorizeService('deploy'); + $this->checkDeployments(); if ($this->isDeploymentProgress) { $this->dispatch('error', 'There is a deployment in progress.'); @@ -155,6 +176,15 @@ public function pullAndRestartEvent() $this->dispatch('activityMonitor', $activity->id); } + private function authorizeService(string $ability): void + { + $this->service = Service::ownedByCurrentTeam() + ->whereKey($this->service->getKey()) + ->firstOrFail(); + + $this->authorize($ability, $this->service); + } + public function render() { return view('livewire.project.service.heading', [ diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index cb2d977bc..12c0edbca 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -108,10 +108,16 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->currentRoute = request()->route()->getName(); - $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); - if (! $this->service) { - return redirect()->route('dashboard'); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', $this->parameters['project_uuid']) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', $this->parameters['environment_uuid']) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { diff --git a/tests/Feature/ServiceResourceRoutingTest.php b/tests/Feature/ServiceResourceRoutingTest.php new file mode 100644 index 000000000..f27340330 --- /dev/null +++ b/tests/Feature/ServiceResourceRoutingTest.php @@ -0,0 +1,141 @@ +id = 0; + $settings->save(); + Once::flush(); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA->id, + 'network' => 'team-a-network', + ]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverB->id, + 'network' => 'team-b-network', + ]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->otherService = Service::factory()->create([ + 'server_id' => $this->serverB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => $this->destinationB->getMorphClass(), + 'environment_id' => $this->environmentB->id, + ]); + $this->otherServiceApplication = ServiceApplication::create([ + 'service_id' => $this->otherService->id, + 'name' => 'other-app', + 'image' => 'nginx:alpine', + ]); + $this->otherServiceDatabase = ServiceDatabase::create([ + 'service_id' => $this->otherService->id, + 'name' => 'other-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + $this->ownService = Service::factory()->create([ + 'server_id' => $this->serverA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + 'environment_id' => $this->environmentA->id, + ]); + $this->ownServiceDatabase = ServiceDatabase::create([ + 'service_id' => $this->ownService->id, + 'name' => 'own-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('does not open service application detail route from another team', function () { + $this->withoutExceptionHandling(); + + $this->get(route('project.service.index', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->otherService->uuid, + 'stack_service_uuid' => $this->otherServiceApplication->uuid, + ])); +})->throws(NotFoundHttpException::class); + +test('does not open service database backups route from another team', function () { + $this->withoutExceptionHandling(); + + $this->get(route('project.service.database.backups', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->otherService->uuid, + 'stack_service_uuid' => $this->otherServiceDatabase->uuid, + ])); +})->throws(NotFoundHttpException::class); + +test('does not resolve service database import component from another team', function () { + $component = app(DatabaseImport::class); + $component->parameters = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->otherService->uuid, + 'stack_service_uuid' => $this->otherServiceDatabase->uuid, + ]; + + $component->getContainers(); +})->throws(ModelNotFoundException::class); + +test('service heading does not hydrate with another team service', function () { + Livewire::test(Heading::class, ['service' => $this->otherService]); +})->throws(ModelNotFoundException::class); + +test('owner can still hydrate service heading with own service', function () { + Livewire::test(Heading::class, [ + 'service' => $this->ownService, + 'parameters' => [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->ownService->uuid, + ], + ]) + ->assertOk(); +}); From 29b372d17aff363166224f99b5630d721543c97f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 13:53:35 +0200 Subject: [PATCH 049/122] fix(echo): support default export constructor Handle both direct and default Echo exports when initializing the Pusher broadcaster. --- resources/views/layouts/base.blade.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 33968ee32..be7b928ab 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -172,7 +172,8 @@ function checkTheme() { } @auth window.Pusher = Pusher; - window.Echo = new Echo({ + const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default; + window.Echo = new EchoConstructor({ broadcaster: 'pusher', cluster: "{{ config('constants.pusher.host') }}" || window.location.hostname, key: "{{ config('constants.pusher.app_key') }}" || 'coolify', From 283795ba94260425e487ce902d0adbd0ab492604 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 14:00:54 +0200 Subject: [PATCH 050/122] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index f4a32be6a..10db1085c 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.1.0', + 'version' => '4.1.1', 'helper_version' => '1.0.14', 'realtime_version' => '1.0.15', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index f3d826753..78b027918 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.1.0" + "version": "4.1.1" }, "nightly": { "version": "4.2.0" diff --git a/versions.json b/versions.json index f3d826753..78b027918 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.1.0" + "version": "4.1.1" }, "nightly": { "version": "4.2.0" From c1518ba1c0be36da42b6cef06df4b042f5733b01 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 15:32:44 +0200 Subject: [PATCH 051/122] fix(webhook): match manual webhook repositories exactly The manual webhook handlers selected target applications with a `git_repository LIKE %full_name%` substring query, so a payload repository name could match unintended applications when repository names overlap. Add a `MatchesManualWebhookApplications` trait that validates the incoming `owner/repo` value and matches `Application.git_repository` by exact normalized path. Github, Gitlab, Gitea and Bitbucket manual handlers now use it, reject invalid repository input early, and return a consistent generic webhook failure payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/Webhook/Bitbucket.php | 30 ++- .../MatchesManualWebhookApplications.php | 98 +++++++++ app/Http/Controllers/Webhook/Gitea.php | 24 +- app/Http/Controllers/Webhook/Github.php | 24 +- app/Http/Controllers/Webhook/Gitlab.php | 29 +-- tests/Feature/Webhook/WebhookHmacTest.php | 206 +++++++++++++++++- 6 files changed, 351 insertions(+), 60 deletions(-) create mode 100644 app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ee7f25431..d37ba7cee 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -5,6 +5,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -14,6 +15,7 @@ class Bitbucket extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -62,8 +64,14 @@ public function manual(Request $request) $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $commit = data_get($payload, 'pullrequest.source.commit.hash'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); - $applications = $applications->where('git_branch', $branch)->get(); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response([ + 'status' => 'failed', + 'message' => 'Nothing to do. Invalid repository.', + ]); + } + $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response([ 'status' => 'failed', @@ -79,11 +87,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -97,11 +101,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -114,11 +114,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php new file mode 100644 index 000000000..e3a5e9890 --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -0,0 +1,98 @@ +normalizeManualWebhookRepositoryPath($fullName); + } + + /** + * @return Collection + */ + protected function manualWebhookApplications(Builder $query, string $fullName): Collection + { + return $query->get() + ->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName)) + ->values(); + } + + protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool + { + $repositoryPath = $this->canonicalManualWebhookRepository($gitRepository); + + return $repositoryPath !== null && hash_equals($fullName, $repositoryPath); + } + + /** + * @return array{status: string, message: string} + */ + protected function unauthenticatedManualWebhookFailurePayload(): array + { + return [ + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]; + } + + protected function canonicalManualWebhookRepository(?string $gitRepository): ?string + { + if (! is_string($gitRepository)) { + return null; + } + + $gitRepository = trim($gitRepository); + + if ($gitRepository === '') { + return null; + } + + $path = null; + $parts = parse_url($gitRepository); + + if (is_array($parts) && isset($parts['scheme'])) { + $path = data_get($parts, 'path'); + } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { + $path = Str::after($gitRepository, ':'); + } else { + $path = $gitRepository; + } + + if (! is_string($path) || $path === '') { + return null; + } + + return $this->normalizeManualWebhookRepositoryPath($path); + } + + protected function normalizeManualWebhookRepositoryPath(string $path): string + { + $path = trim($path); + $path = strtok($path, '?#') ?: $path; + $path = trim($path, '/'); + $path = preg_replace('/\.git\z/i', '', $path) ?? $path; + + return $path; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 64807d694..be064e380 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -5,6 +5,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ class Gitea extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -58,15 +60,19 @@ public function manual(Request $request) if (! $branch) { return response('Nothing to do. No branch found in the request.'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response('Nothing to do. Invalid repository.'); + } + $applications = Application::query(); if ($x_gitea_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); } } if ($x_gitea_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } @@ -80,11 +86,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitea_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -96,11 +98,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitea_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index b0e11f60c..84d7b81f0 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Jobs\GithubAppPermissionJob; use App\Jobs\ProcessGithubPullRequestWebhook; use App\Models\Application; @@ -18,6 +19,7 @@ class Github extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -66,15 +68,19 @@ public function manual(Request $request) if (! $branch) { return response('Nothing to do. No branch found in the request.'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response('Nothing to do. Invalid repository.'); + } + $applications = Application::query(); if ($x_github_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -93,11 +99,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'mode' => 'manual', ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -109,11 +111,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'mode' => 'manual', ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 205bede8f..231a0b6e5 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -5,6 +5,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ class Gitlab extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -85,9 +87,18 @@ public function manual(Request $request) return response($return_payloads); } } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Nothing to do. Invalid repository.', + ]); + + return response($return_payloads); + } + $applications = Application::query(); if ($x_gitlab_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { $return_payloads->push([ 'status' => 'failed', @@ -98,7 +109,7 @@ public function manual(Request $request) } } if ($x_gitlab_event === 'merge_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { $return_payloads->push([ 'status' => 'failed', @@ -117,11 +128,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitlab_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -132,11 +139,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitlab_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index a06e85309..24d9ed72a 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -51,7 +51,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin ], $payload); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with forged hash', function () { @@ -118,7 +118,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin ]); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with wrong token', function () { @@ -178,7 +178,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin ], $payload); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with non-sha256 algorithm', function () { @@ -263,7 +263,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin ], $payload); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with forged hash', function () { @@ -312,6 +312,204 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin }); }); +describe('Manual Webhook Repository Matching', function () { + test('github rejects empty repository without leaking applications', function () { + $app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => ''], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->toContain('Invalid repository') + ->not->toContain('secret-github-app') + ->not->toContain($app->uuid); + }); + + test('github does not match repository substrings', function () { + $app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->toContain('No applications found') + ->not->toContain('secret-github-app') + ->not->toContain($app->uuid); + }); + + test('github invalid signature does not leak matched application identifiers', function () { + $app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->toContain('Invalid signature') + ->not->toContain('secret-github-app') + ->not->toContain($app->uuid) + ->not->toContain('application_uuid') + ->not->toContain('application_name'); + }); + + test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) { + $app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]); + $body = json_encode($payload); + + $server = ['CONTENT_TYPE' => 'application/json']; + foreach ($headers as $name => $value) { + $server[$name] = $value; + } + + $response = $this->call('POST', $uri, [], [], [], $server, $body); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->toContain('Invalid repository') + ->not->toContain("secret-{$provider}-app") + ->not->toContain($app->uuid); + })->with([ + 'gitlab' => [ + 'gitlab', + '/webhooks/source/gitlab/events/manual', + [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => ''], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitlab-Token' => 'wrong-token'], + ], + 'bitbucket' => [ + 'bitbucket', + '/webhooks/source/bitbucket/events/manual', + [ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => ''], + ], + ['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'], + ], + 'gitea' => [ + 'gitea', + '/webhooks/source/gitea/events/manual', + [ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => ''], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'], + ], + ]); + + test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) { + $app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]); + $body = json_encode($payload); + + $server = ['CONTENT_TYPE' => 'application/json']; + foreach ($headers as $name => $value) { + $server[$name] = $value; + } + + $response = $this->call('POST', $uri, [], [], [], $server, $body); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->toContain('No applications found') + ->not->toContain("secret-{$provider}-app") + ->not->toContain($app->uuid); + })->with([ + 'gitlab' => [ + 'gitlab', + '/webhooks/source/gitlab/events/manual', + [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test'], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitlab-Token' => 'wrong-token'], + ], + 'bitbucket' => [ + 'bitbucket', + '/webhooks/source/bitbucket/events/manual', + [ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test'], + ], + ['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'], + ], + 'gitea' => [ + 'gitea', + '/webhooks/source/gitea/events/manual', + [ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test'], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'], + ], + ]); + + test('github matches ssh git repository URL exactly', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@github.com:test-org/test-repo.git', + ]); + $secret = $app->manual_webhook_secret_github; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); +}); + describe('Webhook Secret Auto-Generation', function () { test('auto-generates webhook secrets on application creation', function () { $app = createApplicationWithWebhook(); From 809d9b21fa9609de2ce9b49bb838c079598da876 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 15:59:20 +0200 Subject: [PATCH 052/122] fix(webhook): match manual webhook repositories case-insensitively Git hosts treat owner/repo names case-insensitively, but the exact repository match used a case-sensitive comparison, so a payload whose casing differed from the stored git remote would fail to match and skip a legitimate deployment. Lowercase both canonical repository paths before comparing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MatchesManualWebhookApplications.php | 8 ++++++- tests/Feature/Webhook/WebhookHmacTest.php | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index e3a5e9890..f1fd0c40f 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -42,7 +42,13 @@ protected function manualWebhookRepositoryMatches(?string $gitRepository, string { $repositoryPath = $this->canonicalManualWebhookRepository($gitRepository); - return $repositoryPath !== null && hash_equals($fullName, $repositoryPath); + if ($repositoryPath === null) { + return false; + } + + // Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names + // case-insensitively, so compare the canonical paths case-insensitively. + return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath)); } /** diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index 24d9ed72a..be2417462 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -508,6 +508,29 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin $response->assertOk(); expect($response->getContent())->not->toContain('No applications found'); }); + + test('github matches repository case-insensitively', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'https://github.com/Test-Org/Test-Repo.git', + ]); + $secret = $app->manual_webhook_secret_github; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); }); describe('Webhook Secret Auto-Generation', function () { From e2199f12230bf97279480c37a83b32b3f05dba1f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 16:11:24 +0200 Subject: [PATCH 053/122] fix(queue): route cloud jobs to dedicated queues Use config-based queue selection for deployment and scheduled jobs so cloud dispatches deployments to `deployments` and scheduled jobs to `crons`, while self-hosted keeps using `high`. Add coverage for deployment queue helper, start action routing, and scheduled job manager routing. --- app/Actions/Database/StartDatabase.php | 22 ++++---- app/Actions/Database/StartDatabaseProxy.php | 11 ++-- app/Actions/Service/StartService.php | 6 ++- app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Jobs/ScheduledJobManager.php | 13 ++--- bootstrap/helpers/shared.php | 16 ++++++ tests/Feature/QueueRoutingTest.php | 56 +++++++++++++++++++++ tests/Unit/ScheduledJobManagerLockTest.php | 3 ++ 8 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 tests/Feature/QueueRoutingTest.php diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index e2fa6fc87..4b55b0c1d 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -11,12 +11,16 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; +use Lorisleiva\Actions\Decorators\JobDecorator; class StartDatabase { use AsAction; - public string $jobQueue = 'high'; + public function configureJob(JobDecorator $job): void + { + $job->onQueue(deployment_queue()); + } public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) { @@ -25,28 +29,28 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St return 'Server is not functional'; } switch ($database->getMorphClass()) { - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: $activity = StartPostgresql::run($database); break; - case \App\Models\StandaloneRedis::class: + case StandaloneRedis::class: $activity = StartRedis::run($database); break; - case \App\Models\StandaloneMongodb::class: + case StandaloneMongodb::class: $activity = StartMongodb::run($database); break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: $activity = StartMysql::run($database); break; - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: $activity = StartMariadb::run($database); break; - case \App\Models\StandaloneKeydb::class: + case StandaloneKeydb::class: $activity = StartKeydb::run($database); break; - case \App\Models\StandaloneDragonfly::class: + case StandaloneDragonfly::class: $activity = StartDragonfly::run($database); break; - case \App\Models\StandaloneClickhouse::class: + case StandaloneClickhouse::class: $activity = StartClickhouse::run($database); break; } diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index fa39f7909..1057d1e4d 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -11,14 +11,19 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Notifications\Container\ContainerRestarted; use Lorisleiva\Actions\Concerns\AsAction; +use Lorisleiva\Actions\Decorators\JobDecorator; use Symfony\Component\Yaml\Yaml; class StartDatabaseProxy { use AsAction; - public string $jobQueue = 'high'; + public function configureJob(JobDecorator $job): void + { + $job->onQueue(deployment_queue()); + } public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database) { @@ -29,7 +34,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $proxyContainerName = "{$database->uuid}-proxy"; $isSSLEnabled = $database->enable_ssl ?? false; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($database->getMorphClass() === ServiceDatabase::class) { $databaseType = $database->databaseType(); $network = $database->service->uuid; $server = data_get($database, 'service.destination.server'); @@ -132,7 +137,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St ?? data_get($database, 'service.environment.project.team'); $team?->notify( - new \App\Notifications\Container\ContainerRestarted( + new ContainerRestarted( "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", $server, ) diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 17948d93b..d3d99ff78 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -4,13 +4,17 @@ use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; +use Lorisleiva\Actions\Decorators\JobDecorator; use Symfony\Component\Yaml\Yaml; class StartService { use AsAction; - public string $jobQueue = 'high'; + public function configureJob(JobDecorator $job): void + { + $job->onQueue(deployment_queue()); + } public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 2e43456b8..098cf7804 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -197,7 +197,7 @@ public function tags() public function __construct(public int $application_deployment_queue_id) { - $this->onQueue('high'); + $this->onQueue(deployment_queue()); $this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id); $this->nixpacks_plan_json = collect([]); diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 71829ea41..a601186bf 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -40,14 +40,15 @@ public function __construct() $this->onQueue($this->determineQueue()); } + /** + * On cloud this job runs on a dedicated `crons` queue so it can be drained by an isolated + * Horizon worker pool; self-hosted keeps it on the shared `high` queue. Routing is decided + * by `isCloud()` (config-based), so the dispatching process needs no special env — only + * the worker must be configured to drain `crons` via `HORIZON_QUEUES`. + */ private function determineQueue(): string { - $preferredQueue = 'crons'; - $fallbackQueue = 'high'; - - $configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default')); - - return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue; + return isCloud() ? 'crons' : 'high'; } /** diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 860b550dd..582e6750d 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -592,6 +592,22 @@ function isCloud(): bool return ! config('constants.coolify.self_hosted'); } +/** + * Resolve the queue used for application deployments, database starts and service starts. + * + * On cloud these jobs run on a dedicated `deployments` queue so they can be drained by an + * isolated Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing + * is decided by `isCloud()` (config-based) rather than `HORIZON_QUEUES`, so the dispatching + * process needs no special env — only the worker must be configured to drain `deployments`. + * + * IMPORTANT: on cloud a worker MUST include `deployments` in its `HORIZON_QUEUES`, otherwise + * these jobs are never processed. + */ +function deployment_queue(): string +{ + return isCloud() ? 'deployments' : 'high'; +} + function translate_cron_expression($expression_to_validate): string { if (isset(VALID_CRON_STRINGS[$expression_to_validate])) { diff --git a/tests/Feature/QueueRoutingTest.php b/tests/Feature/QueueRoutingTest.php new file mode 100644 index 000000000..1552c6bb3 --- /dev/null +++ b/tests/Feature/QueueRoutingTest.php @@ -0,0 +1,56 @@ + true]); + + expect(deployment_queue())->toBe('high'); + }); + + test('uses the deployments queue on cloud', function () { + config(['constants.coolify.self_hosted' => false]); + + expect(deployment_queue())->toBe('deployments'); + }); +}); + +describe('start action job routing', function () { + test('routes to the deployments queue on cloud', function (string $actionClass) { + config(['constants.coolify.self_hosted' => false]); + + expect($actionClass::makeJob()->queue)->toBe('deployments'); + })->with([ + StartDatabase::class, + StartDatabaseProxy::class, + StartService::class, + ]); + + test('routes to the high queue on self-hosted', function (string $actionClass) { + config(['constants.coolify.self_hosted' => true]); + + expect($actionClass::makeJob()->queue)->toBe('high'); + })->with([ + StartDatabase::class, + StartDatabaseProxy::class, + StartService::class, + ]); +}); + +describe('scheduled job manager queue routing', function () { + test('uses the crons queue on cloud', function () { + config(['constants.coolify.self_hosted' => false]); + + expect((new ScheduledJobManager)->queue)->toBe('crons'); + }); + + test('uses the high queue on self-hosted', function () { + config(['constants.coolify.self_hosted' => true]); + + expect((new ScheduledJobManager)->queue)->toBe('high'); + }); +}); diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php index 577730f47..924f6f43c 100644 --- a/tests/Unit/ScheduledJobManagerLockTest.php +++ b/tests/Unit/ScheduledJobManagerLockTest.php @@ -2,6 +2,9 @@ use App\Jobs\ScheduledJobManager; use Illuminate\Queue\Middleware\WithoutOverlapping; +use Tests\TestCase; + +uses(TestCase::class); it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () { $job = new ScheduledJobManager; From fcd63f40eb5130ee089c5088d4b534d7e02622bd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 16:26:15 +0200 Subject: [PATCH 054/122] fix(queue): route scheduled jobs through crons helper Centralize scheduled job queue selection with crons_queue() and use it for scheduler, task, and database backup jobs so cloud runs on crons while self-hosted stays on high. --- app/Jobs/DatabaseBackupJob.php | 2 +- app/Jobs/ScheduledJobManager.php | 13 +------------ app/Jobs/ScheduledTaskJob.php | 2 +- bootstrap/helpers/shared.php | 17 +++++++++++++++++ tests/Feature/QueueRoutingTest.php | 24 +++++++++++++++++++++--- 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 207191cbd..bd31ab0c3 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -77,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public ScheduledDatabaseBackup $backup) { - $this->onQueue('high'); + $this->onQueue(crons_queue()); $this->timeout = $backup->timeout ?? 3600; } diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index a601186bf..bd8ee2819 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -37,18 +37,7 @@ class ScheduledJobManager implements ShouldQueue */ public function __construct() { - $this->onQueue($this->determineQueue()); - } - - /** - * On cloud this job runs on a dedicated `crons` queue so it can be drained by an isolated - * Horizon worker pool; self-hosted keeps it on the shared `high` queue. Routing is decided - * by `isCloud()` (config-based), so the dispatching process needs no special env — only - * the worker must be configured to drain `crons` via `HORIZON_QUEUES`. - */ - private function determineQueue(): string - { - return isCloud() ? 'crons' : 'high'; + $this->onQueue(crons_queue()); } /** diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 49b9b9702..67ef1ebe3 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -65,7 +65,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public function __construct($task) { - $this->onQueue('high'); + $this->onQueue(crons_queue()); $this->task = $task; if ($service = $task->service()->first()) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 582e6750d..4bb989de4 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -608,6 +608,23 @@ function deployment_queue(): string return isCloud() ? 'deployments' : 'high'; } +/** + * Resolve the queue used for scheduled jobs — the scheduler dispatcher, scheduled tasks and + * scheduled database backups, whether triggered automatically or manually. + * + * On cloud these jobs run on a dedicated `crons` queue so they can be drained by an isolated + * Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing is decided + * by `isCloud()` (config-based), so the dispatching process needs no special env — only the + * worker must be configured to drain `crons`. + * + * IMPORTANT: on cloud a worker MUST include `crons` in its `HORIZON_QUEUES`, otherwise these + * jobs are never processed. + */ +function crons_queue(): string +{ + return isCloud() ? 'crons' : 'high'; +} + function translate_cron_expression($expression_to_validate): string { if (isset(VALID_CRON_STRINGS[$expression_to_validate])) { diff --git a/tests/Feature/QueueRoutingTest.php b/tests/Feature/QueueRoutingTest.php index 1552c6bb3..1bb837be8 100644 --- a/tests/Feature/QueueRoutingTest.php +++ b/tests/Feature/QueueRoutingTest.php @@ -3,7 +3,9 @@ use App\Actions\Database\StartDatabase; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Service\StartService; +use App\Jobs\DatabaseBackupJob; use App\Jobs\ScheduledJobManager; +use App\Models\ScheduledDatabaseBackup; describe('deployment_queue helper', function () { test('uses the high queue on self-hosted', function () { @@ -19,6 +21,20 @@ }); }); +describe('crons_queue helper', function () { + test('uses the high queue on self-hosted', function () { + config(['constants.coolify.self_hosted' => true]); + + expect(crons_queue())->toBe('high'); + }); + + test('uses the crons queue on cloud', function () { + config(['constants.coolify.self_hosted' => false]); + + expect(crons_queue())->toBe('crons'); + }); +}); + describe('start action job routing', function () { test('routes to the deployments queue on cloud', function (string $actionClass) { config(['constants.coolify.self_hosted' => false]); @@ -41,16 +57,18 @@ ]); }); -describe('scheduled job manager queue routing', function () { - test('uses the crons queue on cloud', function () { +describe('scheduled job routing', function () { + test('scheduled jobs use the crons queue on cloud', function () { config(['constants.coolify.self_hosted' => false]); expect((new ScheduledJobManager)->queue)->toBe('crons'); + expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('crons'); }); - test('uses the high queue on self-hosted', function () { + test('scheduled jobs use the high queue on self-hosted', function () { config(['constants.coolify.self_hosted' => true]); expect((new ScheduledJobManager)->queue)->toBe('high'); + expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('high'); }); }); From 5a7408a919e1128e75f23c2598926814685928f6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 16:34:36 +0200 Subject: [PATCH 055/122] fix(github): improve GitHub App setup and installation flow - resolve the GitHub App by a stable identifier during installation callbacks so installing and re-installing keeps working over the full lifetime of the App - verify the installation id received from the callback against the GitHub API before persisting it - support re-installing an already configured GitHub App instead of blocking it - require an authenticated session and rate limit the setup callback routes - extend manifest setup state validity to match GitHub's manifest code lifetime Adds feature coverage for the GitHub App setup and installation callbacks. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/Webhook/Github.php | 164 ++++++++--- app/Livewire/Source/Github/Change.php | 23 ++ .../livewire/source/github/change.blade.php | 7 +- routes/webhooks.php | 7 +- .../Security/GithubAppSetupCallbackTest.php | 257 ++++++++++++++++++ 5 files changed, 413 insertions(+), 45 deletions(-) create mode 100644 tests/Feature/Security/GithubAppSetupCallbackTest.php diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 84d7b81f0..b481f4a67 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -12,6 +12,7 @@ use App\Models\PrivateKey; use Exception; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -452,53 +453,136 @@ public function normal(Request $request) public function redirect(Request $request) { - try { - $code = $request->get('code'); - $state = $request->get('state'); - $github_app = GithubApp::where('uuid', $state)->firstOrFail(); - $api_url = data_get($github_app, 'api_url'); - $data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json(); - $id = data_get($data, 'id'); - $slug = data_get($data, 'slug'); - $client_id = data_get($data, 'client_id'); - $client_secret = data_get($data, 'client_secret'); - $private_key = data_get($data, 'pem'); - $webhook_secret = data_get($data, 'webhook_secret'); - $private_key = PrivateKey::create([ - 'name' => "github-app-{$slug}", - 'private_key' => $private_key, - 'team_id' => $github_app->team_id, - 'is_git_related' => true, - ]); - $github_app->name = $slug; - $github_app->app_id = $id; - $github_app->client_id = $client_id; - $github_app->client_secret = $client_secret; - $github_app->webhook_secret = $webhook_secret; - $github_app->private_key_id = $private_key->id; - $github_app->save(); + $code = (string) $request->query('code', ''); + abort_if(blank($code), 422, 'Missing GitHub App manifest code.'); - return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); - } catch (Exception $e) { - return handleError($e); - } + $github_app = $this->consumeGithubAppSetupState( + request: $request, + state: (string) $request->query('state', ''), + action: 'manifest', + ); + + abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.'); + + $api_url = data_get($github_app, 'api_url'); + $data = Http::withBody(null) + ->accept('application/vnd.github+json') + ->timeout(10) + ->connectTimeout(5) + ->post("$api_url/app-manifests/$code/conversions") + ->throw() + ->json(); + + $id = data_get($data, 'id'); + $slug = data_get($data, 'slug'); + $client_id = data_get($data, 'client_id'); + $client_secret = data_get($data, 'client_secret'); + $private_key = data_get($data, 'pem'); + $webhook_secret = data_get($data, 'webhook_secret'); + + abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.'); + + $private_key = PrivateKey::create([ + 'name' => "github-app-{$slug}", + 'private_key' => $private_key, + 'team_id' => $github_app->team_id, + 'is_git_related' => true, + ]); + $github_app->name = $slug; + $github_app->app_id = $id; + $github_app->client_id = $client_id; + $github_app->client_secret = $client_secret; + $github_app->webhook_secret = $webhook_secret; + $github_app->private_key_id = $private_key->id; + $github_app->save(); + + return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); } public function install(Request $request) { - try { - $installation_id = $request->get('installation_id'); - $source = $request->get('source'); - $setup_action = $request->get('setup_action'); - $github_app = GithubApp::where('uuid', $source)->firstOrFail(); - if ($setup_action === 'install') { - $github_app->installation_id = $installation_id; - $github_app->save(); - } + $source = (string) $request->query('source', ''); + abort_if(blank($source), 404); + $github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail(); + + $setup_action = (string) $request->query('setup_action', ''); + if ($setup_action !== 'install') { return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); - } catch (Exception $e) { - return handleError($e); + } + + $installation_id = (string) $request->query('installation_id', ''); + abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.'); + + abort_unless( + $this->githubInstallationBelongsToApp($github_app, $installation_id), + 403, + 'GitHub App installation could not be verified.' + ); + + $github_app->installation_id = $installation_id; + $github_app->save(); + + return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); + } + + /** + * Verify that the given installation id actually belongs to this GitHub App. + * + * The installation id arrives as an untrusted query parameter on an + * unauthenticated-reachable GET callback, so it must be confirmed against + * the GitHub API using the App's own credentials before it is persisted. + */ + private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool + { + if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) { + return false; + } + + try { + $jwt = generateGithubJwt($github_app); + $response = Http::withHeaders([ + 'Authorization' => "Bearer $jwt", + 'Accept' => 'application/vnd.github+json', + ]) + ->timeout(10) + ->connectTimeout(5) + ->get("{$github_app->api_url}/app/installations/{$installation_id}"); + + return $response->successful() + && (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id; + } catch (\Throwable) { + return false; } } + + private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp + { + abort_if(blank($state), 404); + + $payload = Cache::pull($this->githubAppSetupStateCacheKey($state)); + abort_unless(is_array($payload), 404); + abort_unless(data_get($payload, 'action') === $action, 404); + + $team_id = $request->user()?->currentTeam()?->id; + abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403); + + return GithubApp::whereKey(data_get($payload, 'github_app_id')) + ->where('team_id', data_get($payload, 'team_id')) + ->firstOrFail(); + } + + private function githubAppSetupStateCacheKey(string $state): string + { + return 'github-app-setup-state:'.hash('sha256', $state); + } + + private function githubAppHasManifestCredentials(GithubApp $github_app): bool + { + return filled($github_app->app_id) + || filled($github_app->client_id) + || filled($github_app->client_secret) + || filled($github_app->webhook_secret) + || filled($github_app->private_key_id); + } } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index d6537069c..cc9ceea8a 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -7,7 +7,9 @@ use App\Models\PrivateKey; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; @@ -72,6 +74,8 @@ class Change extends Component public $privateKeys; + public string $manifestState = ''; + protected function rules(): array { return [ @@ -147,6 +151,24 @@ private function syncData(bool $toModel = false): void } } + private function githubAppSetupStateCacheKey(string $state): string + { + return 'github-app-setup-state:'.hash('sha256', $state); + } + + private function createGithubAppSetupState(string $action): string + { + $state = Str::random(64); + + Cache::put($this->githubAppSetupStateCacheKey($state), [ + 'action' => $action, + 'github_app_id' => $this->github_app->id, + 'team_id' => $this->github_app->team_id, + ], now()->addMinutes(60)); + + return $state; + } + public function checkPermissions() { try { @@ -211,6 +233,7 @@ public function mount() // Override name with kebab case for display $this->name = str($this->github_app->name)->kebab(); $this->fqdn = $settings->fqdn; + $this->manifestState = $this->createGithubAppSetupState('manifest'); if ($settings->public_ipv4) { $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index e47fb0ae0..623897de5 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -290,8 +290,8 @@ class="" function createGithubApp(webhook_endpoint, preview_deployment_permissions, administration) { const { organization, - uuid, - html_url + html_url, + uuid } = @json($github_app); if (!webhook_endpoint) { alert('Please select a webhook endpoint.'); @@ -299,6 +299,7 @@ function createGithubApp(webhook_endpoint, preview_deployment_permissions, admin } let baseUrl = webhook_endpoint; const name = @js($name); + const manifestState = @js($manifestState); const isDev = @js(config('app.env')) === 'local'; const devWebhook = @js(config('constants.webhooks.dev_webhook')); @@ -340,7 +341,7 @@ function createGithubApp(webhook_endpoint, preview_deployment_permissions, admin }; const form = document.createElement('form'); form.setAttribute('method', 'post'); - form.setAttribute('action', `${html_url}/${path}?state=${uuid}`); + form.setAttribute('action', `${html_url}/${path}?state=${manifestState}`); const input = document.createElement('input'); input.setAttribute('id', 'manifest'); input.setAttribute('name', 'manifest'); diff --git a/routes/webhooks.php b/routes/webhooks.php index d8d8e094a..804fd7bcb 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -7,8 +7,11 @@ use App\Http\Controllers\Webhook\Stripe; use Illuminate\Support\Facades\Route; -Route::get('/source/github/redirect', [Github::class, 'redirect']); -Route::get('/source/github/install', [Github::class, 'install']); +Route::middleware(['web', 'auth', 'throttle:30,1'])->group(function () { + Route::get('/source/github/redirect', [Github::class, 'redirect']); + Route::get('/source/github/install', [Github::class, 'install']); +}); + Route::post('/source/github/events', [Github::class, 'normal']); Route::post('/source/github/events/manual', [Github::class, 'manual']); diff --git a/tests/Feature/Security/GithubAppSetupCallbackTest.php b/tests/Feature/Security/GithubAppSetupCallbackTest.php new file mode 100644 index 000000000..9c6893fd1 --- /dev/null +++ b/tests/Feature/Security/GithubAppSetupCallbackTest.php @@ -0,0 +1,257 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); +}); + +function cacheGithubAppSetupState(string $state, string $action, GithubApp $githubApp): void +{ + Cache::put('github-app-setup-state:'.hash('sha256', $state), [ + 'action' => $action, + 'github_app_id' => $githubApp->id, + 'team_id' => $githubApp->team_id, + ], now()->addMinutes(15)); +} + +function authenticateGithubSetupCallbackTest(object $test): void +{ + $test->actingAs($test->user); + session(['currentTeam' => $test->team]); +} + +function fakeGithubManifestConversion(): void +{ + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($key, $privateKey); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/app-manifests/*/conversions' => Http::response([ + 'id' => 987654, + 'slug' => 'attacker-controlled-app', + 'client_id' => 'new-client-id', + 'client_secret' => 'new-client-secret', + 'pem' => $privateKey, + 'webhook_secret' => 'new-webhook-secret', + ]), + ]); +} + +function configureGithubAppCredentials(GithubApp $githubApp): void +{ + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($key, $privateKey); + + $privateKeyModel = PrivateKey::create([ + 'name' => 'github-app-test-key', + 'private_key' => $privateKey, + 'team_id' => $githubApp->team_id, + 'is_git_related' => true, + ]); + + $githubApp->forceFill([ + 'app_id' => 123456, + 'private_key_id' => $privateKeyModel->id, + ])->save(); +} + +function fakeGithubInstallationVerification(int $appId): void +{ + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'Date' => now()->toRfc7231String(), + ]), + 'https://api.github.com/app/installations/*' => Http::response([ + 'id' => 555, + 'app_id' => $appId, + ], 200), + ]); +} + +function fakeGithubInstallationVerificationFailure(): void +{ + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'Date' => now()->toRfc7231String(), + ]), + 'https://api.github.com/app/installations/*' => Http::response(['message' => 'Not Found'], 404), + ]); +} + +it('requires authentication before processing github app manifest callbacks', function () { + fakeGithubManifestConversion(); + cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp); + + $this->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code') + ->assertRedirect(); + + Http::assertNothingSent(); + + $this->githubApp->refresh(); + expect($this->githubApp->app_id)->toBeNull() + ->and($this->githubApp->client_id)->toBeNull() + ->and($this->githubApp->webhook_secret)->toBeNull(); +}); + +it('rejects github app manifest callbacks with invalid state without calling github', function () { + authenticateGithubSetupCallbackTest($this); + fakeGithubManifestConversion(); + + $this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state='.$this->githubApp->uuid.'&code=attacker-code') + ->assertNotFound(); + + Http::assertNothingSent(); + + $this->githubApp->refresh(); + expect($this->githubApp->app_id)->toBeNull() + ->and($this->githubApp->client_id)->toBeNull() + ->and($this->githubApp->webhook_secret)->toBeNull(); +}); + +it('blocks rebinding an already configured github app through manifest callback', function () { + authenticateGithubSetupCallbackTest($this); + fakeGithubManifestConversion(); + + $this->githubApp->forceFill([ + 'app_id' => 123456, + 'client_id' => 'existing-client-id', + 'client_secret' => 'existing-client-secret', + 'webhook_secret' => 'existing-webhook-secret', + ])->save(); + + cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp); + + $this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code') + ->assertForbidden(); + + Http::assertNothingSent(); + + $this->githubApp->refresh(); + expect($this->githubApp->app_id)->toBe(123456) + ->and($this->githubApp->client_id)->toBe('existing-client-id') + ->and($this->githubApp->webhook_secret)->toBe('existing-webhook-secret'); +}); + +it('configures an unbound github app with a valid one-time manifest state', function () { + authenticateGithubSetupCallbackTest($this); + fakeGithubManifestConversion(); + cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp); + + $this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code') + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid])); + + Http::assertSentCount(1); + + $this->githubApp->refresh(); + expect($this->githubApp->name)->toBe('attacker-controlled-app') + ->and($this->githubApp->app_id)->toBe(987654) + ->and($this->githubApp->client_id)->toBe('new-client-id') + ->and($this->githubApp->webhook_secret)->toBe('new-webhook-secret') + ->and($this->githubApp->private_key_id)->not->toBeNull(); +}); + +it('rejects replayed github app manifest states', function () { + authenticateGithubSetupCallbackTest($this); + fakeGithubManifestConversion(); + cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp); + + $this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code') + ->assertRedirect(); + + $this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=real-code') + ->assertNotFound(); + + Http::assertSentCount(1); +}); + +it('requires authentication before processing github app install callbacks', function () { + Http::preventStrayRequests(); + + $this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456') + ->assertRedirect(); + + Http::assertNothingSent(); + + $this->githubApp->refresh(); + expect($this->githubApp->installation_id)->toBeNull(); +}); + +it('rejects github app install callbacks for an unknown github app', function () { + authenticateGithubSetupCallbackTest($this); + Http::preventStrayRequests(); + + $this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456') + ->assertNotFound(); + + Http::assertNothingSent(); +}); + +it('rejects an installation id that github does not confirm belongs to the app', function () { + authenticateGithubSetupCallbackTest($this); + configureGithubAppCredentials($this->githubApp); + fakeGithubInstallationVerificationFailure(); + + $this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999') + ->assertForbidden(); + + $this->githubApp->refresh(); + expect($this->githubApp->installation_id)->toBeNull(); +}); + +it('sets installation id when github confirms it belongs to the app', function () { + authenticateGithubSetupCallbackTest($this); + configureGithubAppCredentials($this->githubApp); + fakeGithubInstallationVerification($this->githubApp->app_id); + + $this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456') + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid])); + + $this->githubApp->refresh(); + expect($this->githubApp->installation_id)->toBe(123456); +}); + +it('allows reinstalling an already configured github app installation id', function () { + authenticateGithubSetupCallbackTest($this); + configureGithubAppCredentials($this->githubApp); + $this->githubApp->forceFill(['installation_id' => 111111])->save(); + fakeGithubInstallationVerification($this->githubApp->app_id); + + $this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222') + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid])); + + $this->githubApp->refresh(); + expect($this->githubApp->installation_id)->toBe(222222); +}); From 182df1cb079392173fe98a6633d5eb59698ecb81 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 17:00:08 +0200 Subject: [PATCH 056/122] fix(logs): keep stream polling active without collapsible panel Move log stream polling off the loading indicator so non-collapsible log panels continue polling while streaming, and cover the behavior with a Livewire feature test. --- .../livewire/project/shared/get-logs.blade.php | 5 ++++- tests/Feature/GetLogsCommandInjectionTest.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 4ef77081e..230a2c22a 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -274,10 +274,13 @@
({{ $pull_request }})
@endif @if ($streamLogs) - + @endif
@endif + @if ($streamLogs) + + @endif
diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index c0b17c3bd..db75f7b75 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -130,6 +130,20 @@ }); }); +describe('GetLogs stream polling', function () { + test('streaming logs polls when log panel is not collapsible', function () { + Livewire::test(GetLogs::class, [ + 'server' => $this->server, + 'resource' => $this->application, + 'container' => 'coolify-sentinel', + 'collapsible' => false, + ]) + ->assertDontSeeHtml('wire:poll.2000ms="getLogs(true)"') + ->call('toggleStreamLogs') + ->assertSeeHtml('wire:poll.2000ms="getLogs(true)"'); + }); +}); + describe('GetLogs container name injection payloads are blocked by validation', function () { test('newline injection payload is rejected', function () { // The exact PoC payload from the advisory From 57d879263d2fab8cca1d400ad28f007c4b615c51 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 17:31:38 +0200 Subject: [PATCH 057/122] fix(ssh): prevent orphaned multiplexed connections Serialize multiplexed SSH master creation per server to avoid concurrent workers spawning orphaned processes. Enable scheduled cleanup for stale mux connections and add guarded orphan process reaping with tests. --- app/Console/Kernel.php | 3 +- app/Helpers/SshMultiplexingHelper.php | 98 ++++++-- .../CleanupStaleMultiplexedConnections.php | 156 ++++++++++++- config/constants.php | 4 + tests/Feature/SshMultiplexingLockTest.php | 219 ++++++++++++++++++ 5 files changed, 460 insertions(+), 20 deletions(-) create mode 100644 tests/Feature/SshMultiplexingLockTest.php diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3ec59adb3..665553fcb 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,6 +8,7 @@ use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; +use App\Jobs\CleanupStaleMultiplexedConnections; use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; @@ -40,7 +41,7 @@ protected function schedule(Schedule $schedule): void $this->instanceTimezone = config('app.timezone'); } - // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); + $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly()->onOneServer(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 4629df571..78084a157 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,6 +4,7 @@ use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; @@ -30,37 +31,99 @@ public static function ensureMultiplexedConnection(Server $server): bool return false; } + // Fast path: a usable master already exists, no need to lock. + if (self::connectionIsReusable($server)) { + return true; + } + + // Slow path: establishing or refreshing the master. Serialize per server + // so concurrent workers do not each spawn their own master process, + // leaving orphaned non-master ssh connections that ControlPersist never reaps. + try { + return Cache::lock( + self::connectionLockKey($server), + config('constants.ssh.mux_lock_ttl') + )->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) { + // Double-checked: another worker may have established the master + // while we were waiting for the lock. + if (self::connectionIsReusable($server)) { + return true; + } + + // A master exists but is stale or expired: close and re-establish. + if (self::masterConnectionExists($server)) { + return self::refreshMultiplexedConnection($server); + } + + return self::establishNewMultiplexedConnection($server); + }); + } catch (LockTimeoutException) { + Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + ]); + + return false; + } catch (\Throwable $e) { + Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Per-server, per-host lock key for serializing master establishment. + * + * The mux socket is a host-local unix socket, so the lock is scoped to the + * current Coolify host: workers on the same host share a master and must + * serialize, while workers on other hosts manage their own masters and must + * not block on each other. + */ + private static function connectionLockKey(Server $server): string + { + return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; + } + + /** + * Check whether a multiplexed master connection currently exists for the server. + */ + private static function masterConnectionExists(Server $server): bool + { $sshConfig = self::serverSshConfiguration($server); $muxSocket = $sshConfig['muxFilename']; - // Check if connection exists $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; if (data_get($server, 'settings.is_cloudflare_tunnel')) { $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } $checkCommand .= self::escapedUserAtHost($server); - $process = Process::run($checkCommand); - if ($process->exitCode() !== 0) { - return self::establishNewMultiplexedConnection($server); + return Process::run($checkCommand)->exitCode() === 0; + } + + /** + * Determine whether the existing master connection can be reused as-is + * (it exists, has not exceeded its max age, and passes the health check). + */ + private static function connectionIsReusable(Server $server): bool + { + if (! self::masterConnectionExists($server)) { + return false; } - // Connection exists, ensure we have metadata for age tracking + // Existing connection but no metadata, store current time as fallback. if (self::getConnectionAge($server) === null) { - // Existing connection but no metadata, store current time as fallback self::storeConnectionMetadata($server); } - // Connection exists, check if it needs refresh due to age if (self::isConnectionExpired($server)) { - return self::refreshMultiplexedConnection($server); + return false; } - // Perform health check if enabled - if (config('constants.ssh.mux_health_check_enabled')) { - if (! self::isConnectionHealthy($server)) { - return self::refreshMultiplexedConnection($server); - } + if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) { + return false; } return true; @@ -75,7 +138,10 @@ public static function establishNewMultiplexedConnection(Server $server): bool $serverInterval = config('constants.ssh.server_interval'); $muxPersistTime = config('constants.ssh.mux_persist_time'); - $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + // No -M: it forces master mode and overrides ControlMaster=auto. When a + // socket already exists -M leaves an orphaned non-master ssh -fN process + // that ControlPersist never reaps. ControlMaster=auto reuses instead. + $establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; if (data_get($server, 'settings.is_cloudflare_tunnel')) { $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; @@ -176,6 +242,10 @@ public static function generateSshCommand(Server $server, string $command, bool $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; } } catch (\Exception $e) { + Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); // Continue without multiplexing } } diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index 6d49bee4b..0d3029c66 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; @@ -20,6 +21,132 @@ public function handle() { $this->cleanupStaleConnections(); $this->cleanupNonExistentServerConnections(); + $this->cleanupOrphanedSshProcesses(); + $this->cleanupOrphanedCloudflaredProcesses(); + } + + /** + * Kill backgrounded ssh master processes that lost the ControlPath socket + * race. Such processes are not masters, so ControlPersist never reaps them + * and they leak memory until the container restarts. A legitimate master + * always owns its socket file; an orphan has none. + * + * Processes younger than the minimum age are skipped: a freshly forked + * master creates its socket a few milliseconds after starting, so a young + * process with no socket may simply be mid-establish rather than orphaned. + */ + private function cleanupOrphanedSshProcesses(): void + { + $muxDir = storage_path('app/ssh/mux'); + $minAge = (int) config('constants.ssh.mux_orphan_min_age'); + + foreach ($this->listProcesses() as $process) { + // Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`. + if (! preg_match('#(^|/)ssh -fN#', $process['args'])) { + continue; + } + + // Only ever touch ssh processes pointing at Coolify's mux directory. + if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) { + continue; + } + + if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) { + $this->reapOrphan('ssh', $process); + } + } + } + + /** + * Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned + * as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must + * die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost + * mux master), the cloudflared process can leak and accumulate. A legitimate + * proxy always has a live ssh parent; one without is safe to reap. + * + * Processes younger than the minimum age are skipped so a proxy whose parent + * ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is + * never mistaken for an orphan. + */ + private function cleanupOrphanedCloudflaredProcesses(): void + { + $minAge = (int) config('constants.ssh.mux_orphan_min_age'); + $processes = $this->listProcesses(); + + $sshPids = []; + foreach ($processes as $process) { + // The ssh binary itself, not `cloudflared access ssh` (space before ssh). + if (preg_match('#(^|/)ssh\s#', $process['args'])) { + $sshPids[$process['pid']] = true; + } + } + + foreach ($processes as $process) { + // `cloudflared access ssh`, never the `cloudflared tunnel` daemon. + if (! str_contains($process['args'], 'cloudflared access ssh')) { + continue; + } + + // Orphaned when no live ssh process is its parent. + if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) { + $this->reapOrphan('cloudflared', $process); + } + } + } + + /** + * Reap a detected orphan process. When orphan reaping is disabled (the + * default), the orphan is only logged — a dry-run mode that lets operators + * verify what would be killed before enabling it for real. + * + * @param array{pid: string, ppid: string, etimes: int, args: string} $process + */ + private function reapOrphan(string $kind, array $process): void + { + if (! config('constants.ssh.mux_orphan_reap_enabled')) { + Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [ + 'pid' => $process['pid'], + 'etimes' => $process['etimes'], + 'command' => $process['args'], + ]); + + return; + } + + Process::run('kill '.escapeshellarg($process['pid'])); + Log::info("Killed orphaned {$kind} process", [ + 'pid' => $process['pid'], + 'etimes' => $process['etimes'], + 'command' => $process['args'], + ]); + } + + /** + * Snapshot of running processes. + * + * @return list + */ + private function listProcesses(): array + { + $ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args='); + if ($ps->exitCode() !== 0) { + return []; + } + + $processes = []; + foreach (explode("\n", trim($ps->output())) as $line) { + if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) { + continue; + } + $processes[] = [ + 'pid' => $matches[1], + 'ppid' => $matches[2], + 'etimes' => (int) $matches[3], + 'args' => $matches[4], + ]; + } + + return $processes; } private function cleanupStaleConnections() @@ -31,7 +158,7 @@ private function cleanupStaleConnections() $server = Server::where('uuid', $serverUuid)->first(); if (! $server) { - $this->removeMultiplexFile($muxFile); + $this->removeMultiplexFile($muxFile, 'server_not_found'); continue; } @@ -41,14 +168,14 @@ private function cleanupStaleConnections() $checkProcess = Process::run($checkCommand); if ($checkProcess->exitCode() !== 0) { - $this->removeMultiplexFile($muxFile); + $this->removeMultiplexFile($muxFile, 'connection_check_failed'); } else { $muxContent = Storage::disk('ssh-mux')->get($muxFile); $establishedAt = Carbon::parse(substr($muxContent, 37)); $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); if (Carbon::now()->isAfter($expirationTime)) { - $this->removeMultiplexFile($muxFile); + $this->removeMultiplexFile($muxFile, 'expired'); } } } @@ -62,7 +189,7 @@ private function cleanupNonExistentServerConnections() foreach ($muxFiles as $muxFile) { $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); if (! in_array($serverUuid, $existingServerUuids)) { - $this->removeMultiplexFile($muxFile); + $this->removeMultiplexFile($muxFile, 'server_does_not_exist'); } } } @@ -72,11 +199,30 @@ private function extractServerUuidFromMuxFile($muxFile) return substr($muxFile, 4); } - private function removeMultiplexFile($muxFile) + /** + * Close and delete a stale mux socket file. When orphan reaping is disabled + * (the default), the file is only logged — a dry-run mode that lets operators + * verify what would be removed before enabling it for real. + */ + private function removeMultiplexFile(string $muxFile, string $reason): void { + if (! config('constants.ssh.mux_orphan_reap_enabled')) { + Log::info('Stale mux file detected (dry-run, not removed)', [ + 'file' => $muxFile, + 'reason' => $reason, + ]); + + return; + } + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; Process::run($closeCommand); Storage::disk('ssh-mux')->delete($muxFile); + + Log::info('Removed stale mux file', [ + 'file' => $muxFile, + 'reason' => $reason, + ]); } } diff --git a/config/constants.php b/config/constants.php index 10db1085c..7721ff213 100644 --- a/config/constants.php +++ b/config/constants.php @@ -70,6 +70,10 @@ 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes + 'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds + 'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds + 'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds + 'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 3600, diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php new file mode 100644 index 000000000..8d56f5235 --- /dev/null +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -0,0 +1,219 @@ +create(); + $team = $user->teams()->first(); + + $privateKey = PrivateKey::create([ + 'name' => 'mux-test-key-'.uniqid(), + 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n". + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n". + "QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n". + "hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n". + "AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n". + "uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n". + '-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $team->id, + ]); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); +} + +it('establishes a master with ssh -fN and never the orphan-prone ssh -fNM', function () { + config(['constants.ssh.mux_enabled' => true]); + $server = makeMuxServer(); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 1), // no existing master + '*-fN *' => Process::result(exitCode: 0), // establish succeeds + ]); + + expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ') + && ! str_contains($process->command, 'ssh -fNM')); +}); + +it('reuses an existing healthy master without spawning a new one', function () { + config([ + 'constants.ssh.mux_enabled' => true, + 'constants.ssh.mux_health_check_enabled' => true, + ]); + $server = makeMuxServer(); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 0), + '*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0), + ]); + + expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN')); +}); + +it('does not spawn a master when the per-server lock is already held', function () { + config([ + 'constants.ssh.mux_enabled' => true, + 'constants.ssh.mux_lock_timeout' => 0, + ]); + $server = makeMuxServer(); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 1), // forces the slow path + ]); + + // Simulate another worker on the same host holding the lock for this server. + $lockKey = 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; + $held = Cache::lock($lockKey, 30); + expect($held->get())->toBeTrue(); + + expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); + + $held->release(); +}); + +it('returns false and runs no ssh when multiplexing is disabled', function () { + config(['constants.ssh.mux_enabled' => false]); + $server = makeMuxServer(); + + Process::fake(); + + expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse(); + + Process::assertNothingRan(); +}); + +it('kills only old orphaned ssh masters whose control socket no longer exists', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + $muxDir = storage_path('app/ssh/mux'); + File::ensureDirectoryExists($muxDir); + + $liveSocket = $muxDir.'/mux_live_'.uniqid(); + $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); + $youngSocket = $muxDir.'/mux_young_'.uniqid(); + File::put($liveSocket, 'x'); // live master owns its socket file; the others do not + + // Columns: pid ppid etimes args + Process::fake([ + 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4\n". + "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n". + "333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + // Old orphan: killed. + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222')); + // Live master (socket exists): spared. + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); + // Young process (may be mid-establish): spared despite missing socket. + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333')); + + File::delete($liveSocket); +}); + +it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + // pid 100 = live ssh master; 200 = its legit child; 300 = old orphan; + // 400 = young orphan (parent ssh may still be starting up). + Process::fake([ + 'ps*' => Process::result(output: "100 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4\n". + "200 100 5000 cloudflared access ssh --hostname host.example.com\n". + "300 2176 5000 cloudflared access ssh --hostname host.example.com\n". + "400 2176 30 cloudflared access ssh --hostname host.example.com\n". + "2176 1 9000 /usr/bin/some-supervisor\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedCloudflaredProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + // Old orphan (parent not ssh): killed. + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '300')); + // Legit proxy (parent ssh alive): spared. + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '200')); + // Young orphan: spared. + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '400')); +}); + +it('dry-run mode logs orphans but kills nothing when reaping is disabled', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => false]); + $muxDir = storage_path('app/ssh/mux'); + File::ensureDirectoryExists($muxDir); + + $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); // no file: a real old orphan + + Process::fake([ + 'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + // Orphan detected, but dry-run: nothing killed. + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); +}); + +it('removes mux files for non-existent servers when reaping is enabled', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake(); // the `ssh -O exit` close command + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse(); +}); + +it('keeps mux files for non-existent servers in dry-run mode', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => false]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake(); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue(); + Process::assertNothingRan(); +}); From bd744eb8dd9c3071c2c4b4fd7498faa4825764bb Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 22 May 2026 21:22:50 +0530 Subject: [PATCH 058/122] fix(ui): configuration changes modal values, colors and spacing --- app/Models/Application.php | 21 ++++--- .../ApplicationConfigurationSnapshot.php | 4 +- .../ConfigurationDiffer.php | 4 +- .../deployment/configuration-diff.blade.php | 58 +++++++++---------- .../ApplicationConfigurationChangedTest.php | 14 +++-- .../Livewire/ConfigurationCheckerTest.php | 7 +-- .../ApplicationConfigurationSnapshotTest.php | 8 +-- 7 files changed, 58 insertions(+), 58 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 97b257752..fd7f486b9 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1188,17 +1188,20 @@ public function pendingDeploymentConfigurationDiff(): ConfigurationDiff $currentSnapshot = $this->deploymentConfigurationSnapshot(); $lastDeployment = $this->get_last_successful_deployment(); - if ($lastDeployment?->configuration_snapshot) { - return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot); + $previousSnapshot = $lastDeployment?->configuration_snapshot; + + if (! $previousSnapshot) { + $oldConfigHash = data_get($this, 'config_hash'); + $hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash(); + + if (! $hasLegacyChange) { + return ConfigurationDiff::unchanged(); + } + + $previousSnapshot = []; } - $oldConfigHash = data_get($this, 'config_hash'); - - if ($oldConfigHash === null) { - return ConfigurationDiff::legacy(true); - } - - return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash()); + return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot); } public function hasPendingDeploymentConfigurationChanges(): bool diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 676b22b6c..8369f9a90 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -306,7 +306,7 @@ private function normalizeValue(mixed $value): mixed private function displayValue(mixed $value): string { if ($value === null) { - return 'Not set'; + return '-'; } if (is_bool($value)) { @@ -323,7 +323,7 @@ private function displayValue(mixed $value): string private function summarizeText(?string $value): string { if (blank($value)) { - return 'Not set'; + return '-'; } $value = trim((string) $value); diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 27e8d4c3f..b101b9d5b 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -37,8 +37,8 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura 'impact' => data_get($item, 'impact', 'redeploy'), 'sensitive' => $sensitive, 'display_summary' => $displaySummary, - 'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'), - 'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'), + 'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'), + 'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'), ]; } diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php index ffc0cd34a..f01481057 100644 --- a/resources/views/components/deployment/configuration-diff.blade.php +++ b/resources/views/components/deployment/configuration-diff.blade.php @@ -4,9 +4,9 @@ ]) @php - $changes = data_get($diff, 'changes', []); - $count = data_get($diff, 'count', count($changes)); - $requiresBuild = data_get($diff, 'requires_build', false); + $changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all(); + $count = count($changes); + $requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build'); @endphp @if ($count > 0) @@ -21,45 +21,39 @@ 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild, 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild, ])> - {{ $requiresBuild ? 'Rebuild' : 'Redeploy' }} + {{ $requiresBuild ? 'Rebuild required' : 'Redeploy required' }}
@unless ($compact) -
+
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
{{ $sectionLabel }}
-
-
-
-
Field
-
Type
-
From
-
-
To
-
-
- @foreach ($sectionChanges as $change) -
-
- {{ data_get($change, 'label') }} -
-
- {{ data_get($change, 'type') }} -
-
- {{ data_get($change, 'old_display_value') }} -
-
-
- {{ data_get($change, 'new_display_value') }} -
+
+
+
Field
+
From
+
+
To
+
+
+ @foreach ($sectionChanges as $change) +
+
+ {{ data_get($change, 'label') }}
- @endforeach -
+
+ {{ data_get($change, 'old_display_value') }} +
+
+
+ {{ data_get($change, 'new_display_value') }} +
+
+ @endforeach
diff --git a/tests/Feature/ApplicationConfigurationChangedTest.php b/tests/Feature/ApplicationConfigurationChangedTest.php index f862f840d..b91e9f289 100644 --- a/tests/Feature/ApplicationConfigurationChangedTest.php +++ b/tests/Feature/ApplicationConfigurationChangedTest.php @@ -80,11 +80,11 @@ function configurationChangedDeployment(Application $application): ApplicationDe $diff = $application->pendingDeploymentConfigurationDiff(); - expect($diff->isLegacyFallback())->toBeTrue() - ->and($diff->isChanged())->toBeTrue(); + expect($diff->isChanged())->toBeTrue() + ->and($diff->count())->toBeGreaterThan(0); }); -it('falls back to legacy configuration hash when no deployment snapshot exists', function () { +it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () { $application = configurationChangedTestApplication(); $application->isConfigurationChanged(save: true); @@ -92,6 +92,10 @@ function configurationChangedDeployment(Application $application): ApplicationDe $application->update(['build_command' => 'pnpm build']); - expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue() - ->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue(); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->isChanged())->toBeTrue() + ->and($diff->isLegacyFallback())->toBeFalse() + ->and($diff->count())->toBeGreaterThan(0) + ->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command'); }); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index edf8c5044..d9e6729c8 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -126,8 +126,7 @@ function markConfigurationCheckerApplicationDeployed(Application $application): Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) ->assertSee('API_TOKEN') - ->assertSee('changed') - ->assertSee('Set') + ->assertSee('••••••••') ->assertDontSee('Hidden') ->assertDontSee('old-secret') ->assertDontSee('new-secret'); @@ -150,9 +149,9 @@ function markConfigurationCheckerApplicationDeployed(Application $application): Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) ->assertSee('API_TOKEN') ->assertSee('From') - ->assertSee('Not set') + ->assertSee('-') ->assertSee('To') - ->assertSee('Set') + ->assertSee('••••••••') ->assertDontSee('Hidden') ->assertDontSee('new-secret'); }); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 2106697b2..20b7c0adc 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -93,8 +93,8 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat expect($change)->not->toBeNull() ->and($change['display_summary'])->toBe('Changed') - ->and($change['old_display_value'])->toBe('Set') - ->and($change['new_display_value'])->toBe('Set') + ->and($change['old_display_value'])->toBe('••••••••') + ->and($change['new_display_value'])->toBe('••••••••') ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); }); @@ -117,7 +117,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() - ->and($change['old_display_value'])->toBe('Not set') - ->and($change['new_display_value'])->toBe('Set') + ->and($change['old_display_value'])->toBe('-') + ->and($change['new_display_value'])->toBe('••••••••') ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); }); From 54a020cf1b1774391ab89b8b6c372bf0a1e2a7ab Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 18:01:53 +0200 Subject: [PATCH 059/122] fix(ssh): rely on lazy multiplexed connections Remove explicit SSH master pre-warming and lock handling so OpenSSH manages ControlMaster creation lazily from real ssh/scp commands. Add cleanup for duplicate mux processes and update coverage around mux command options and stale process cleanup. --- app/Helpers/SshMultiplexingHelper.php | 315 ++---------------- .../CleanupStaleMultiplexedConnections.php | 71 ++++ tests/Feature/SshMultiplexingLockTest.php | 156 +++++---- 3 files changed, 198 insertions(+), 344 deletions(-) diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 78084a157..167d8d54f 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,8 +4,6 @@ use App\Models\PrivateKey; use App\Models\Server; -use Illuminate\Contracts\Cache\LockTimeoutException; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; @@ -13,210 +11,60 @@ class SshMultiplexingHelper { - public static function serverSshConfiguration(Server $server) + public static function serverSshConfiguration(Server $server): array { $privateKey = PrivateKey::findOrFail($server->private_key_id); - $sshKeyLocation = $privateKey->getKeyLocation(); - $muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; return [ - 'sshKeyLocation' => $sshKeyLocation, - 'muxFilename' => $muxFilename, + 'sshKeyLocation' => $privateKey->getKeyLocation(), + 'muxFilename' => self::muxSocket($server), ]; } public static function ensureMultiplexedConnection(Server $server): bool { - if (! self::isMultiplexingEnabled()) { - return false; - } - - // Fast path: a usable master already exists, no need to lock. - if (self::connectionIsReusable($server)) { - return true; - } - - // Slow path: establishing or refreshing the master. Serialize per server - // so concurrent workers do not each spawn their own master process, - // leaving orphaned non-master ssh connections that ControlPersist never reaps. - try { - return Cache::lock( - self::connectionLockKey($server), - config('constants.ssh.mux_lock_ttl') - )->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) { - // Double-checked: another worker may have established the master - // while we were waiting for the lock. - if (self::connectionIsReusable($server)) { - return true; - } - - // A master exists but is stale or expired: close and re-establish. - if (self::masterConnectionExists($server)) { - return self::refreshMultiplexedConnection($server); - } - - return self::establishNewMultiplexedConnection($server); - }); - } catch (LockTimeoutException) { - Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [ - 'server' => $server->name ?? $server->ip, - ]); - - return false; - } catch (\Throwable $e) { - Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [ - 'server' => $server->name ?? $server->ip, - 'error' => $e->getMessage(), - ]); - - return false; - } + return self::isMultiplexingEnabled(); } - /** - * Per-server, per-host lock key for serializing master establishment. - * - * The mux socket is a host-local unix socket, so the lock is scoped to the - * current Coolify host: workers on the same host share a master and must - * serialize, while workers on other hosts manage their own masters and must - * not block on each other. - */ - private static function connectionLockKey(Server $server): string + public static function removeMuxFile(Server $server): void { - return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; - } - - /** - * Check whether a multiplexed master connection currently exists for the server. - */ - private static function masterConnectionExists(Server $server): bool - { - $sshConfig = self::serverSshConfiguration($server); - $muxSocket = $sshConfig['muxFilename']; - - $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $checkCommand .= self::escapedUserAtHost($server); - - return Process::run($checkCommand)->exitCode() === 0; - } - - /** - * Determine whether the existing master connection can be reused as-is - * (it exists, has not exceeded its max age, and passes the health check). - */ - private static function connectionIsReusable(Server $server): bool - { - if (! self::masterConnectionExists($server)) { - return false; - } - - // Existing connection but no metadata, store current time as fallback. - if (self::getConnectionAge($server) === null) { - self::storeConnectionMetadata($server); - } - - if (self::isConnectionExpired($server)) { - return false; - } - - if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) { - return false; - } - - return true; - } - - public static function establishNewMultiplexedConnection(Server $server): bool - { - $sshConfig = self::serverSshConfiguration($server); - $sshKeyLocation = $sshConfig['sshKeyLocation']; - $muxSocket = $sshConfig['muxFilename']; - $connectionTimeout = self::getConnectionTimeout($server); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - // No -M: it forces master mode and overrides ControlMaster=auto. When a - // socket already exists -M leaves an orphaned non-master ssh -fN process - // that ControlPersist never reaps. ControlMaster=auto reuses instead. - $establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); - $establishCommand .= self::escapedUserAtHost($server); - $establishProcess = Process::run($establishCommand); - if ($establishProcess->exitCode() !== 0) { - return false; - } - - // Store connection metadata for tracking - self::storeConnectionMetadata($server); - - return true; - } - - public static function removeMuxFile(Server $server) - { - $sshConfig = self::serverSshConfiguration($server); - $muxSocket = $sshConfig['muxFilename']; - - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket "; + $closeCommand = 'ssh -O exit -o ControlPath='.self::muxSocket($server).' '; if (data_get($server, 'settings.is_cloudflare_tunnel')) { $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } $closeCommand .= self::escapedUserAtHost($server); - Process::run($closeCommand); - // Clear connection metadata from cache - self::clearConnectionMetadata($server); + Process::run($closeCommand); } - public static function generateScpCommand(Server $server, string $source, string $dest) + public static function generateScpCommand(Server $server, string $source, string $dest): string { $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; - $muxSocket = $sshConfig['muxFilename']; + $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp '; - $timeout = config('constants.ssh.command_timeout'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $scp_command = "timeout $timeout scp "; if ($server->isIpv6()) { - $scp_command .= '-6 '; + $scpCommand .= '-6 '; } + if (self::isMultiplexingEnabled()) { - try { - if (self::ensureMultiplexedConnection($server)) { - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - } - } catch (\Exception $e) { - Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [ - 'server' => $server->name ?? $server->ip, - 'error' => $e->getMessage(), - ]); - // Continue without multiplexing - } + $scpCommand .= self::multiplexingOptions($server); } if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + $scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); + $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); + if ($server->isIpv6()) { - $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; - } else { - $scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}"; + return $scpCommand."{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } - return $scp_command; + return $scpCommand."{$source} ".self::escapedUserAtHost($server).":{$dest}"; } - public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false) + public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string { if ($server->settings->force_disabled) { throw new \RuntimeException('Server is disabled.'); @@ -227,44 +75,36 @@ public static function generateSshCommand(Server $server, string $command, bool self::validateSshKey($server->privateKey); - $muxSocket = $sshConfig['muxFilename']; + $sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh '; - $timeout = config('constants.ssh.command_timeout'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $ssh_command = "timeout $timeout ssh "; - - $multiplexingSuccessful = false; if (! $disableMultiplexing && self::isMultiplexingEnabled()) { - try { - $multiplexingSuccessful = self::ensureMultiplexedConnection($server); - if ($multiplexingSuccessful) { - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - } - } catch (\Exception $e) { - Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [ - 'server' => $server->name ?? $server->ip, - 'error' => $e->getMessage(), - ]); - // Continue without multiplexing - } + $sshCommand .= self::multiplexingOptions($server); } if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; + $sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; } - $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); + $sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); - $delimiter = Hash::make($command); - $delimiter = base64_encode($delimiter); + $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); - $ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; + } - return $ssh_command; + private static function multiplexingOptions(Server $server): string + { + return '-o ControlMaster=auto ' + .'-o ControlPath='.self::muxSocket($server).' ' + .'-o ControlPersist='.config('constants.ssh.mux_persist_time').' '; + } + + private static function muxSocket(Server $server): string + { + return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; } private static function escapedUserAtHost(Server $server): string @@ -301,7 +141,6 @@ private static function validateSshKey(PrivateKey $privateKey): void $privateKey->storeInFileSystem(); } - // Ensure correct permissions (SSH requires 0600) if (file_exists($keyLocation)) { $currentPerms = fileperms($keyLocation) & 0777; if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) { @@ -332,90 +171,10 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati .'-o RequestTTY=no ' .'-o LogLevel=ERROR '; - // Bruh if ($isScp) { - $options .= '-P '.escapeshellarg((string) $server->port).' '; - } else { - $options .= '-p '.escapeshellarg((string) $server->port).' '; + return $options.'-P '.escapeshellarg((string) $server->port).' '; } - return $options; - } - - /** - * Check if the multiplexed connection is healthy by running a test command - */ - public static function isConnectionHealthy(Server $server): bool - { - $sshConfig = self::serverSshConfiguration($server); - $muxSocket = $sshConfig['muxFilename']; - $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout'); - - $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'"; - - $process = Process::run($healthCommand); - $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); - - return $isHealthy; - } - - /** - * Check if the connection has exceeded its maximum age - */ - public static function isConnectionExpired(Server $server): bool - { - $connectionAge = self::getConnectionAge($server); - $maxAge = config('constants.ssh.mux_max_age'); - - return $connectionAge !== null && $connectionAge > $maxAge; - } - - /** - * Get the age of the current connection in seconds - */ - public static function getConnectionAge(Server $server): ?int - { - $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; - $connectionTime = Cache::get($cacheKey); - - if ($connectionTime === null) { - return null; - } - - return time() - $connectionTime; - } - - /** - * Refresh a multiplexed connection by closing and re-establishing it - */ - public static function refreshMultiplexedConnection(Server $server): bool - { - // Close existing connection - self::removeMuxFile($server); - - // Establish new connection - return self::establishNewMultiplexedConnection($server); - } - - /** - * Store connection metadata when a new connection is established - */ - private static function storeConnectionMetadata(Server $server): void - { - $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; - Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time - } - - /** - * Clear connection metadata from cache - */ - private static function clearConnectionMetadata(Server $server): void - { - $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; - Cache::forget($cacheKey); + return $options.'-p '.escapeshellarg((string) $server->port).' '; } } diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index 0d3029c66..92e485702 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -21,10 +21,44 @@ public function handle() { $this->cleanupStaleConnections(); $this->cleanupNonExistentServerConnections(); + $this->cleanupDuplicateSshProcesses(); $this->cleanupOrphanedSshProcesses(); $this->cleanupOrphanedCloudflaredProcesses(); } + /** + * Once two background ssh masters share the same ControlPath, OpenSSH's + * control socket state is no longer trustworthy: `ssh -O check` may report + * one PID while the socket lifecycle is tied to another. Reset the whole + * duplicate group rather than trying to choose an owner. + */ + private function cleanupDuplicateSshProcesses(): void + { + $muxDir = storage_path('app/ssh/mux'); + $groups = []; + + foreach ($this->listProcesses() as $process) { + if (! preg_match('#(^|/)ssh -fN#', $process['args'])) { + continue; + } + + $controlPath = $this->extractControlPath($process['args']); + if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) { + continue; + } + + $groups[$controlPath][] = $process; + } + + foreach ($groups as $controlPath => $processes) { + if (count($processes) < 2) { + continue; + } + + $this->resetDuplicateGroup($controlPath, $processes); + } + } + /** * Kill backgrounded ssh master processes that lost the ControlPath socket * race. Such processes are not masters, so ControlPersist never reaps them @@ -149,6 +183,43 @@ private function listProcesses(): array return $processes; } + /** + * @param list $processes + */ + private function resetDuplicateGroup(string $controlPath, array $processes): void + { + if (! config('constants.ssh.mux_orphan_reap_enabled')) { + Log::info('Duplicate ssh mux processes detected (dry-run, not killed)', [ + 'control_path' => $controlPath, + 'pids' => array_column($processes, 'pid'), + ]); + + return; + } + + foreach ($processes as $process) { + Process::run('kill '.escapeshellarg($process['pid'])); + } + + if (file_exists($controlPath)) { + @unlink($controlPath); + } + + Log::info('Reset duplicate ssh mux processes', [ + 'control_path' => $controlPath, + 'pids' => array_column($processes, 'pid'), + ]); + } + + private function extractControlPath(string $args): ?string + { + if (! preg_match('/(?:^|\s)-o\s+ControlPath=(?:"([^"]+)"|\'([^\']+)\'|(\S+))/', $args, $matches)) { + return null; + } + + return $matches[1] ?: ($matches[2] ?: $matches[3]); + } + private function cleanupStaleConnections() { $muxFiles = Storage::disk('ssh-mux')->files(); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index 8d56f5235..e34b8a735 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -6,15 +6,14 @@ use App\Models\Server; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; /** - * Tests for the per-server lock that prevents concurrent workers from each - * spawning their own SSH master, which leaves orphaned non-master ssh - * connections that ControlPersist never reaps (memory leak). + * SSH multiplexing now relies on OpenSSH's native lazy ControlMaster handling. + * Coolify should add mux options to real ssh/scp commands, but must not pre-warm + * background masters with separate `ssh -fN` processes. */ uses(RefreshDatabase::class); @@ -23,80 +22,91 @@ function makeMuxServer(): Server $user = User::factory()->create(); $team = $user->teams()->first(); + $privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n". + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n". + "QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n". + "hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n". + "AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n". + "uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n". + '-----END OPENSSH PRIVATE KEY-----'; + $privateKey = PrivateKey::create([ 'name' => 'mux-test-key-'.uniqid(), - 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n". - "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n". - "QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n". - "hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n". - "AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n". - "uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n". - '-----END OPENSSH PRIVATE KEY-----', + 'private_key' => $privateKeyContent, 'team_id' => $team->id, ]); + Storage::fake('ssh-keys'); + Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent); + return Server::factory()->create([ 'team_id' => $team->id, 'private_key_id' => $privateKey->id, ]); } -it('establishes a master with ssh -fN and never the orphan-prone ssh -fNM', function () { +it('does not prewarm a background ssh master', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); - Process::fake([ - '*-O check*' => Process::result(exitCode: 1), // no existing master - '*-fN *' => Process::result(exitCode: 0), // establish succeeds - ]); + Process::fake(); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); - Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ') - && ! str_contains($process->command, 'ssh -fNM')); + Process::assertNothingRan(); }); -it('reuses an existing healthy master without spawning a new one', function () { - config([ - 'constants.ssh.mux_enabled' => true, - 'constants.ssh.mux_health_check_enabled' => true, - ]); +it('adds native openssh multiplexing options to ssh commands', function () { + config(['constants.ssh.mux_enabled' => true]); + $server = makeMuxServer(); + Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key); + + Process::fake(); + + $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); + + expect($command) + ->toContain('-o ControlMaster=auto') + ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") + ->toContain('-o ControlPersist=3600') + ->not->toContain('ssh -fN') + ->not->toContain('-O check'); + + Process::assertNothingRan(); +}); + +it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () { + config(['constants.ssh.mux_enabled' => true]); + $server = makeMuxServer(); + Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key); + + $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true); + + expect($command) + ->not->toContain('-o ControlMaster=auto') + ->not->toContain('-o ControlPath=') + ->not->toContain('-o ControlPersist='); +}); + +it('adds native openssh multiplexing options to scp commands', function () { + config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); - Process::fake([ - '*-O check*' => Process::result(exitCode: 0), - '*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0), - ]); + Process::fake(); - expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); + $command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest'); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN')); + expect($command) + ->toContain('-o ControlMaster=auto') + ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") + ->toContain('-o ControlPersist=3600') + ->not->toContain('ssh -fN') + ->not->toContain('-O check'); + + Process::assertNothingRan(); }); -it('does not spawn a master when the per-server lock is already held', function () { - config([ - 'constants.ssh.mux_enabled' => true, - 'constants.ssh.mux_lock_timeout' => 0, - ]); - $server = makeMuxServer(); - - Process::fake([ - '*-O check*' => Process::result(exitCode: 1), // forces the slow path - ]); - - // Simulate another worker on the same host holding the lock for this server. - $lockKey = 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; - $held = Cache::lock($lockKey, 30); - expect($held->get())->toBeTrue(); - - expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse(); - - Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); - - $held->release(); -}); - -it('returns false and runs no ssh when multiplexing is disabled', function () { +it('returns false and runs no process when multiplexing is globally disabled', function () { config(['constants.ssh.mux_enabled' => false]); $server = makeMuxServer(); @@ -115,9 +125,8 @@ function makeMuxServer(): Server $liveSocket = $muxDir.'/mux_live_'.uniqid(); $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); $youngSocket = $muxDir.'/mux_young_'.uniqid(); - File::put($liveSocket, 'x'); // live master owns its socket file; the others do not + File::put($liveSocket, 'x'); - // Columns: pid ppid etimes args Process::fake([ 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4\n". "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n". @@ -130,11 +139,8 @@ function makeMuxServer(): Server $method->setAccessible(true); $method->invoke($job); - // Old orphan: killed. Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222')); - // Live master (socket exists): spared. Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); - // Young process (may be mid-establish): spared despite missing socket. Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333')); File::delete($liveSocket); @@ -142,8 +148,7 @@ function makeMuxServer(): Server it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); - // pid 100 = live ssh master; 200 = its legit child; 300 = old orphan; - // 400 = young orphan (parent ssh may still be starting up). + Process::fake([ 'ps*' => Process::result(output: "100 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4\n". "200 100 5000 cloudflared access ssh --hostname host.example.com\n". @@ -158,11 +163,8 @@ function makeMuxServer(): Server $method->setAccessible(true); $method->invoke($job); - // Old orphan (parent not ssh): killed. Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '300')); - // Legit proxy (parent ssh alive): spared. Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '200')); - // Young orphan: spared. Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '400')); }); @@ -171,7 +173,7 @@ function makeMuxServer(): Server $muxDir = storage_path('app/ssh/mux'); File::ensureDirectoryExists($muxDir); - $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); // no file: a real old orphan + $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); Process::fake([ 'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n"), @@ -183,16 +185,38 @@ function makeMuxServer(): Server $method->setAccessible(true); $method->invoke($job); - // Orphan detected, but dry-run: nothing killed. Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); }); +it('resets duplicate ssh mux process groups atomically when reaping is enabled', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + $muxDir = storage_path('app/ssh/mux'); + File::ensureDirectoryExists($muxDir); + $controlPath = $muxDir.'/mux_duplicate_'.uniqid(); + File::put($controlPath, 'socket'); + + Process::fake([ + 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n". + "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222')); + expect(file_exists($controlPath))->toBeFalse(); +}); + it('removes mux files for non-existent servers when reaping is enabled', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); Storage::fake('ssh-mux'); $file = 'mux_ghost'.uniqid(); Storage::disk('ssh-mux')->put($file, 'x'); - Process::fake(); // the `ssh -O exit` close command + Process::fake(); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); From 5c67766f41a1df9b05e4955428d15a7772040358 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 18:17:37 +0200 Subject: [PATCH 060/122] fix(ssh): serialize initial mux connection creation Wrap first-use SSH and SCP multiplexed commands with a lock to avoid racing while the control socket is created. Also detect native OpenSSH mux master process names during stale connection cleanup and cover both orphaned and duplicate mux processes with tests. --- app/Helpers/SshMultiplexingHelper.php | 92 ++++++++++++++++++- .../CleanupStaleMultiplexedConnections.php | 18 ++-- tests/Feature/SshMultiplexingLockTest.php | 54 +++++++++++ 3 files changed, 148 insertions(+), 16 deletions(-) diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 167d8d54f..6984dac4b 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -41,13 +41,14 @@ public static function generateScpCommand(Server $server, string $source, string { $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; + $multiplexingEnabled = self::isMultiplexingEnabled(); $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp '; if ($server->isIpv6()) { $scpCommand .= '-6 '; } - if (self::isMultiplexingEnabled()) { + if ($multiplexingEnabled) { $scpCommand .= self::multiplexingOptions($server); } @@ -58,10 +59,14 @@ public static function generateScpCommand(Server $server, string $source, string $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { - return $scpCommand."{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; + $scpCommand .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; + } else { + $scpCommand .= "{$source} ".self::escapedUserAtHost($server).":{$dest}"; } - return $scpCommand."{$source} ".self::escapedUserAtHost($server).":{$dest}"; + return $multiplexingEnabled + ? self::withFirstUseMuxLock($server, $scpCommand) + : $scpCommand; } public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string @@ -72,12 +77,13 @@ public static function generateSshCommand(Server $server, string $command, bool $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; + $multiplexingEnabled = ! $disableMultiplexing && self::isMultiplexingEnabled(); self::validateSshKey($server->privateKey); $sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh '; - if (! $disableMultiplexing && self::isMultiplexingEnabled()) { + if ($multiplexingEnabled) { $sshCommand .= self::multiplexingOptions($server); } @@ -90,9 +96,13 @@ public static function generateSshCommand(Server $server, string $command, bool $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); - return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + $sshCommand .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; + + return $multiplexingEnabled + ? self::withFirstUseMuxLock($server, $sshCommand) + : $sshCommand; } private static function multiplexingOptions(Server $server): string @@ -107,6 +117,78 @@ private static function muxSocket(Server $server): string return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; } + private static function muxLockDirectory(Server $server): string + { + return self::muxSocket($server).'.lock'; + } + + private static function withFirstUseMuxLock(Server $server, string $command): string + { + $muxSocket = self::muxSocket($server); + $lockDirectory = self::muxLockDirectory($server); + $lockTimeout = (int) config('constants.ssh.mux_lock_timeout'); + + $script = <<<'SH' +cmd=$1 +socket=$2 +lock=$3 +timeout=$4 + +run_command() { + sh -c "$cmd" +} + +if [ -S "$socket" ]; then + run_command + exit $? +fi + +waited=0 +while ! mkdir "$lock" 2>/dev/null; do + if [ -S "$socket" ]; then + run_command + exit $? + fi + + if [ "$waited" -ge "$timeout" ]; then + run_command + exit $? + fi + + waited=$((waited + 1)) + sleep 1 +done + +cleanup() { + if [ -n "${child:-}" ] && kill -0 "$child" 2>/dev/null; then + kill "$child" 2>/dev/null + fi + rmdir "$lock" 2>/dev/null +} +trap cleanup INT TERM HUP + +sh -c "$cmd" & +child=$! + +for _ in 1 2 3 4 5 6 7 8 9 10; do + if [ -S "$socket" ] || ! kill -0 "$child" 2>/dev/null; then + break + fi + sleep 0.1 +done + +rmdir "$lock" 2>/dev/null +wait "$child" +exit $? +SH; + + return 'sh -c '.escapeshellarg($script).' -- ' + .escapeshellarg($command).' ' + .escapeshellarg($muxSocket).' ' + .escapeshellarg($lockDirectory).' ' + .escapeshellarg((string) $lockTimeout); + } + private static function escapedUserAtHost(Server $server): string { return escapeshellarg($server->user).'@'.escapeshellarg($server->ip); diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index 92e485702..0a10fa420 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -38,10 +38,6 @@ private function cleanupDuplicateSshProcesses(): void $groups = []; foreach ($this->listProcesses() as $process) { - if (! preg_match('#(^|/)ssh -fN#', $process['args'])) { - continue; - } - $controlPath = $this->extractControlPath($process['args']); if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) { continue; @@ -75,17 +71,13 @@ private function cleanupOrphanedSshProcesses(): void $minAge = (int) config('constants.ssh.mux_orphan_min_age'); foreach ($this->listProcesses() as $process) { - // Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`. - if (! preg_match('#(^|/)ssh -fN#', $process['args'])) { - continue; - } - // Only ever touch ssh processes pointing at Coolify's mux directory. - if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) { + $controlPath = $this->extractControlPath($process['args']); + if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) { continue; } - if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) { + if ($process['etimes'] >= $minAge && ! file_exists($controlPath)) { $this->reapOrphan('ssh', $process); } } @@ -214,6 +206,10 @@ private function resetDuplicateGroup(string $controlPath, array $processes): voi private function extractControlPath(string $args): ?string { if (! preg_match('/(?:^|\s)-o\s+ControlPath=(?:"([^"]+)"|\'([^\']+)\'|(\S+))/', $args, $matches)) { + if (preg_match('/^ssh:\s+(\S+)\s+\[mux\]$/', $args, $matches)) { + return $matches[1]; + } + return null; } diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index e34b8a735..c39d5a48f 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -66,8 +66,10 @@ function makeMuxServer(): Server $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); expect($command) + ->toStartWith('sh -c') ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") + ->toContain("/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}.lock") ->toContain('-o ControlPersist=3600') ->not->toContain('ssh -fN') ->not->toContain('-O check'); @@ -83,6 +85,7 @@ function makeMuxServer(): Server $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true); expect($command) + ->not->toStartWith('sh -c') ->not->toContain('-o ControlMaster=auto') ->not->toContain('-o ControlPath=') ->not->toContain('-o ControlPersist='); @@ -97,8 +100,10 @@ function makeMuxServer(): Server $command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest'); expect($command) + ->toStartWith('sh -c') ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") + ->toContain("/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}.lock") ->toContain('-o ControlPersist=3600') ->not->toContain('ssh -fN') ->not->toContain('-O check'); @@ -146,6 +151,32 @@ function makeMuxServer(): Server File::delete($liveSocket); }); +it('kills old orphaned native openssh mux masters whose control socket no longer exists', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + $muxDir = storage_path('app/ssh/mux'); + File::ensureDirectoryExists($muxDir); + + $liveSocket = $muxDir.'/mux_native_live_'.uniqid(); + $orphanSocket = $muxDir.'/mux_native_orphan_'.uniqid(); + File::put($liveSocket, 'x'); + + Process::fake([ + 'ps*' => Process::result(output: "111 1 5000 ssh: {$liveSocket} [mux]\n". + "222 1 5000 ssh: {$orphanSocket} [mux]\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); + + File::delete($liveSocket); +}); + it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); @@ -211,6 +242,29 @@ function makeMuxServer(): Server expect(file_exists($controlPath))->toBeFalse(); }); +it('resets duplicate native openssh mux process groups atomically when reaping is enabled', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + $muxDir = storage_path('app/ssh/mux'); + File::ensureDirectoryExists($muxDir); + $controlPath = $muxDir.'/mux_native_duplicate_'.uniqid(); + File::put($controlPath, 'socket'); + + Process::fake([ + 'ps*' => Process::result(output: "111 1 5000 ssh: {$controlPath} [mux]\n". + "222 1 5000 ssh: {$controlPath} [mux]\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222')); + expect(file_exists($controlPath))->toBeFalse(); +}); + it('removes mux files for non-existent servers when reaping is enabled', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); Storage::fake('ssh-mux'); From a13fb3cf00018dba45b8ae83ca2466d92cd79593 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 18:22:22 +0200 Subject: [PATCH 061/122] fix(ssh): verify mux readiness before reusing socket Use ssh -O check in the first-use mux lock flow so commands only reuse a multiplexed socket after the control master is actually ready. --- app/Helpers/SshMultiplexingHelper.php | 33 ++++++++++++++++------- tests/Feature/SshMultiplexingLockTest.php | 8 +++--- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 6984dac4b..db139d110 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -28,15 +28,20 @@ public static function ensureMultiplexedConnection(Server $server): bool public static function removeMuxFile(Server $server): void { - $closeCommand = 'ssh -O exit -o ControlPath='.self::muxSocket($server).' '; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $closeCommand .= self::escapedUserAtHost($server); - + $closeCommand = self::muxControlCommand($server, 'exit'); Process::run($closeCommand); } + private static function muxControlCommand(Server $server, string $operation): string + { + $command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' '; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + return $command.self::escapedUserAtHost($server); + } + public static function generateScpCommand(Server $server, string $source, string $dest): string { $sshConfig = self::serverSshConfiguration($server); @@ -128,24 +133,31 @@ private static function withFirstUseMuxLock(Server $server, string $command): st $lockDirectory = self::muxLockDirectory($server); $lockTimeout = (int) config('constants.ssh.mux_lock_timeout'); + $checkCommand = self::muxControlCommand($server, 'check'); + $script = <<<'SH' cmd=$1 socket=$2 lock=$3 timeout=$4 +check=$5 run_command() { sh -c "$cmd" } -if [ -S "$socket" ]; then +mux_ready() { + [ -S "$socket" ] && sh -c "$check" >/dev/null 2>&1 +} + +if mux_ready; then run_command exit $? fi waited=0 while ! mkdir "$lock" 2>/dev/null; do - if [ -S "$socket" ]; then + if mux_ready; then run_command exit $? fi @@ -171,7 +183,7 @@ private static function withFirstUseMuxLock(Server $server, string $command): st child=$! for _ in 1 2 3 4 5 6 7 8 9 10; do - if [ -S "$socket" ] || ! kill -0 "$child" 2>/dev/null; then + if mux_ready || ! kill -0 "$child" 2>/dev/null; then break fi sleep 0.1 @@ -186,7 +198,8 @@ private static function withFirstUseMuxLock(Server $server, string $command): st .escapeshellarg($command).' ' .escapeshellarg($muxSocket).' ' .escapeshellarg($lockDirectory).' ' - .escapeshellarg((string) $lockTimeout); + .escapeshellarg((string) $lockTimeout).' ' + .escapeshellarg($checkCommand); } private static function escapedUserAtHost(Server $server): string diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index c39d5a48f..ff8ff20bc 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -71,8 +71,8 @@ function makeMuxServer(): Server ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain("/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}.lock") ->toContain('-o ControlPersist=3600') - ->not->toContain('ssh -fN') - ->not->toContain('-O check'); + ->toContain('-O check') + ->not->toContain('ssh -fN'); Process::assertNothingRan(); }); @@ -105,8 +105,8 @@ function makeMuxServer(): Server ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain("/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}.lock") ->toContain('-o ControlPersist=3600') - ->not->toContain('ssh -fN') - ->not->toContain('-O check'); + ->toContain('-O check') + ->not->toContain('ssh -fN'); Process::assertNothingRan(); }); From a05878650954ce465c5a570e71a8790e8b9ce3a1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 18:27:40 +0200 Subject: [PATCH 062/122] fix(ssh): remove mux first-use lock wrapper Rely on OpenSSH lazy multiplexing directly for SSH and SCP commands, removing the shell lock wrapper and related readiness checks. --- app/Helpers/SshMultiplexingHelper.php | 100 ++-------------------- tests/Feature/SshMultiplexingLockTest.php | 9 +- 2 files changed, 7 insertions(+), 102 deletions(-) diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index db139d110..cf92deb2a 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -46,14 +46,13 @@ public static function generateScpCommand(Server $server, string $source, string { $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; - $multiplexingEnabled = self::isMultiplexingEnabled(); $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp '; if ($server->isIpv6()) { $scpCommand .= '-6 '; } - if ($multiplexingEnabled) { + if (self::isMultiplexingEnabled()) { $scpCommand .= self::multiplexingOptions($server); } @@ -64,14 +63,10 @@ public static function generateScpCommand(Server $server, string $source, string $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { - $scpCommand .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; - } else { - $scpCommand .= "{$source} ".self::escapedUserAtHost($server).":{$dest}"; + return $scpCommand."{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } - return $multiplexingEnabled - ? self::withFirstUseMuxLock($server, $scpCommand) - : $scpCommand; + return $scpCommand."{$source} ".self::escapedUserAtHost($server).":{$dest}"; } public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string @@ -82,13 +77,12 @@ public static function generateSshCommand(Server $server, string $command, bool $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; - $multiplexingEnabled = ! $disableMultiplexing && self::isMultiplexingEnabled(); self::validateSshKey($server->privateKey); $sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh '; - if ($multiplexingEnabled) { + if (! $disableMultiplexing && self::isMultiplexingEnabled()) { $sshCommand .= self::multiplexingOptions($server); } @@ -101,13 +95,9 @@ public static function generateSshCommand(Server $server, string $command, bool $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); - $sshCommand .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; - - return $multiplexingEnabled - ? self::withFirstUseMuxLock($server, $sshCommand) - : $sshCommand; } private static function multiplexingOptions(Server $server): string @@ -122,86 +112,6 @@ private static function muxSocket(Server $server): string return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; } - private static function muxLockDirectory(Server $server): string - { - return self::muxSocket($server).'.lock'; - } - - private static function withFirstUseMuxLock(Server $server, string $command): string - { - $muxSocket = self::muxSocket($server); - $lockDirectory = self::muxLockDirectory($server); - $lockTimeout = (int) config('constants.ssh.mux_lock_timeout'); - - $checkCommand = self::muxControlCommand($server, 'check'); - - $script = <<<'SH' -cmd=$1 -socket=$2 -lock=$3 -timeout=$4 -check=$5 - -run_command() { - sh -c "$cmd" -} - -mux_ready() { - [ -S "$socket" ] && sh -c "$check" >/dev/null 2>&1 -} - -if mux_ready; then - run_command - exit $? -fi - -waited=0 -while ! mkdir "$lock" 2>/dev/null; do - if mux_ready; then - run_command - exit $? - fi - - if [ "$waited" -ge "$timeout" ]; then - run_command - exit $? - fi - - waited=$((waited + 1)) - sleep 1 -done - -cleanup() { - if [ -n "${child:-}" ] && kill -0 "$child" 2>/dev/null; then - kill "$child" 2>/dev/null - fi - rmdir "$lock" 2>/dev/null -} -trap cleanup INT TERM HUP - -sh -c "$cmd" & -child=$! - -for _ in 1 2 3 4 5 6 7 8 9 10; do - if mux_ready || ! kill -0 "$child" 2>/dev/null; then - break - fi - sleep 0.1 -done - -rmdir "$lock" 2>/dev/null -wait "$child" -exit $? -SH; - - return 'sh -c '.escapeshellarg($script).' -- ' - .escapeshellarg($command).' ' - .escapeshellarg($muxSocket).' ' - .escapeshellarg($lockDirectory).' ' - .escapeshellarg((string) $lockTimeout).' ' - .escapeshellarg($checkCommand); - } - private static function escapedUserAtHost(Server $server): string { return escapeshellarg($server->user).'@'.escapeshellarg($server->ip); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index ff8ff20bc..fee4ec632 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -66,12 +66,10 @@ function makeMuxServer(): Server $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); expect($command) - ->toStartWith('sh -c') ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") - ->toContain("/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}.lock") ->toContain('-o ControlPersist=3600') - ->toContain('-O check') + ->not->toContain('-O check') ->not->toContain('ssh -fN'); Process::assertNothingRan(); @@ -85,7 +83,6 @@ function makeMuxServer(): Server $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true); expect($command) - ->not->toStartWith('sh -c') ->not->toContain('-o ControlMaster=auto') ->not->toContain('-o ControlPath=') ->not->toContain('-o ControlPersist='); @@ -100,12 +97,10 @@ function makeMuxServer(): Server $command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest'); expect($command) - ->toStartWith('sh -c') ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") - ->toContain("/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}.lock") ->toContain('-o ControlPersist=3600') - ->toContain('-O check') + ->not->toContain('-O check') ->not->toContain('ssh -fN'); Process::assertNothingRan(); From ffe8cfd76f294e1b70b6ac8bdbfea1e9056d0986 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 18:39:37 +0200 Subject: [PATCH 063/122] fix(changelog): use configurable GitHub releases source Default changelog pulls to the GitHub raw releases JSON and cover the configured URL, file writing, and draft-release filtering with feature tests. --- config/constants.php | 2 +- tests/Feature/PullChangelogTest.php | 72 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/PullChangelogTest.php diff --git a/config/constants.php b/config/constants.php index 7721ff213..0a0227ea6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -16,7 +16,7 @@ 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), - 'releases_url' => 'https://cdn.coolify.io/releases.json', + 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'), ], 'urls' => [ diff --git a/tests/Feature/PullChangelogTest.php b/tests/Feature/PullChangelogTest.php new file mode 100644 index 000000000..145638812 --- /dev/null +++ b/tests/Feature/PullChangelogTest.php @@ -0,0 +1,72 @@ + 'v9.9.9', + 'name' => 'Test Release', + 'body' => 'Released notes here.', + 'draft' => false, + 'published_at' => '1999-01-15T00:00:00Z', + ], + [ + 'tag_name' => 'v9.9.8-draft', + 'name' => 'Draft Release', + 'body' => 'Should be skipped.', + 'draft' => true, + 'published_at' => '1999-01-10T00:00:00Z', + ], + ]; +} + +afterEach(function () { + File::delete(base_path('changelogs/1999-01.json')); +}); + +test('releases_url config defaults to the GitHub raw source', function () { + expect(config('constants.coolify.releases_url')) + ->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'); +}); + +test('PullChangelog fetches from the configured releases_url and writes the changelog', function () { + config(['constants.coolify.releases_url' => 'https://example.test/releases.json']); + + Http::fake([ + 'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200), + ]); + + (new PullChangelog)->handle(); + + Http::assertSent(fn ($request) => $request->url() === 'https://example.test/releases.json'); + + $path = base_path('changelogs/1999-01.json'); + expect(File::exists($path))->toBeTrue(); + + $data = json_decode(File::get($path), true); + expect($data['entries'])->toHaveCount(1) + ->and($data['entries'][0]['tag_name'])->toBe('v9.9.9'); +}); + +test('PullChangelog skips draft releases', function () { + config(['constants.coolify.releases_url' => 'https://example.test/releases.json']); + + Http::fake([ + 'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200), + ]); + + (new PullChangelog)->handle(); + + $data = json_decode(File::get(base_path('changelogs/1999-01.json')), true); + + $tags = array_column($data['entries'], 'tag_name'); + expect($tags)->not->toContain('v9.9.8-draft'); +}); From a49bc5dd14a54daf92dc0c316db1fe9e2e03f1de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 23 May 2026 12:15:14 +0200 Subject: [PATCH 064/122] docs(readme): add Seibert Group sponsor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0b76e864a..b387d87e8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API +* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code * [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs * [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control From 9c5c39334a46ed5a302ba5c466a6db89bfb35c0a Mon Sep 17 00:00:00 2001 From: michalzard Date: Mon, 25 May 2026 16:02:48 +0200 Subject: [PATCH 065/122] chore(gitea-runner): bumped version to 1.0.6 --- templates/compose/gitea-runner.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml index 712424881..4cf5ac503 100644 --- a/templates/compose/gitea-runner.yaml +++ b/templates/compose/gitea-runner.yaml @@ -6,7 +6,7 @@ services: runner: - image: 'docker.io/gitea/runner:1.0.5' + image: 'docker.io/gitea/runner:1.0.6' environment: - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}' - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}' From 21db1fd3745694c735410bec986c723795774555 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 May 2026 11:41:04 +0200 Subject: [PATCH 066/122] fix(sync-bunny): sync nightly CDN files to nested paths Write nightly versions and releases under json/nightly in the CDN repo, and cover both release and versions-only sync flows with feature tests. --- app/Console/Commands/SyncBunny.php | 9 +-- templates/service-templates-latest.json | 81 +++++++++++++++++++++++-- templates/service-templates.json | 81 +++++++++++++++++++++++-- tests/Feature/SyncBunnyTest.php | 68 +++++++++++++++++++++ 4 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 tests/Feature/SyncBunnyTest.php diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 9ac3371e0..50bdfaf1e 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -212,7 +212,8 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b $timestamp = time(); $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; $branchName = 'update-releases-and-versions-'.$timestamp; - $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; + $versionsTargetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; + $releasesTargetPath = $nightly ? 'json/nightly/releases.json' : 'json/releases.json'; // 3. Clone the repository $this->info('Cloning coolify-cdn repository...'); @@ -237,7 +238,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b // 5. Write releases.json $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; + $releasesPath = "$tmpDir/$releasesTargetPath"; $releasesDir = dirname($releasesPath); if (! is_dir($releasesDir)) { @@ -282,7 +283,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b // 7. Stage both files $this->info('Staging changes...'); $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); + exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($releasesTargetPath).' '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to stage changes: '.implode("\n", $output)); exec('rm -rf '.escapeshellarg($tmpDir)); @@ -539,7 +540,7 @@ private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightl $timestamp = time(); $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp; $branchName = 'update-versions-'.$timestamp; - $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; + $targetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; // Clone the repository $this->info('Cloning coolify-cdn repository...'); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index d1cebb2ca..b97e5ef9a 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -171,7 +171,7 @@ "audiobookshelf": { "documentation": "https://www.audiobookshelf.org/?utm_source=coolify.io", "slogan": "Self-hosted audiobook, ebook, and podcast server", - "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BVURJT0JPT0tTSEVMRl84MAogICAgICAtICdUWj0ke1RJTUVaT05FOi1BbWVyaWNhL1Rvcm9udG99JwogICAgdm9sdW1lczoKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtYXVkaW9ib29rczovYXVkaW9ib29rcycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtcG9kY2FzdHM6L3BvZGNhc3RzJwogICAgICAtICdhdWRpb2Jvb2tzaGVsZi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtbWV0YWRhdGE6L21ldGFkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tcXVpZXQgLS10cmllcz0xIC0tdGltZW91dD01IGh0dHA6Ly9sb2NhbGhvc3Q6ODAvcGluZyAtTyAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTVzCg==", + "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjoyLjM0LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BVURJT0JPT0tTSEVMRl84MAogICAgICAtICdUWj0ke1RJTUVaT05FOi1BbWVyaWNhL1Rvcm9udG99JwogICAgdm9sdW1lczoKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtYXVkaW9ib29rczovYXVkaW9ib29rcycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtcG9kY2FzdHM6L3BvZGNhc3RzJwogICAgICAtICdhdWRpb2Jvb2tzaGVsZi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtbWV0YWRhdGE6L21ldGFkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tcXVpZXQgLS10cmllcz0xIC0tdGltZW91dD01IGh0dHA6Ly9sb2NhbGhvc3Q6ODAvcGluZyAtTyAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTVzCg==", "tags": [ "audiobooks", "ebooks", @@ -654,6 +654,18 @@ "minversion": "0.0.0", "port": "8978" }, + "cloudflare-ddns": { + "documentation": "https://github.com/favonia/cloudflare-ddns?utm_source=coolify.io", + "slogan": "A small, feature-rich, and robust Cloudflare DDNS updater.", + "compose": "c2VydmljZXM6CiAgY2xvdWRmbGFyZS1kZG5zOgogICAgaW1hZ2U6ICdmYXZvbmlhL2Nsb3VkZmxhcmUtZGRuczoxLjE2LjInCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIHVzZXI6ICcxMDAwOjEwMDAnCiAgICByZWFkX29ubHk6IHRydWUKICAgIGNhcF9kcm9wOgogICAgICAtIGFsbAogICAgc2VjdXJpdHlfb3B0OgogICAgICAtICduby1uZXctcHJpdmlsZWdlczp0cnVlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMT1VERkxBUkVfQVBJX1RPS0VOPSR7Q0xPVURGTEFSRV9BUElfVE9LRU46P30nCiAgICAgIC0gJ0RPTUFJTlM9JHtET01BSU5TOj99JwogICAgICAtICdQUk9YSUVEPSR7UFJPWElFRDotZmFsc2V9JwogICAgICAtICdJUDZfUFJPVklERVI9JHtJUDZfUFJPVklERVI6LW5vbmV9Jwo=", + "tags": [ + "cloud", + "ddns" + ], + "category": "automation", + "logo": "svgs/cloudflare-ddns.svg", + "minversion": "0.0.0" + }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", "slogan": "Client for Cloudflare Tunnel, a daemon that exposes private services through the Cloudflare edge.", @@ -1149,6 +1161,23 @@ "minversion": "0.0.0", "port": "6555" }, + "emqx-enterprise": { + "documentation": "https://www.emqx.io/docs/en/latest/deploy/install-docker.html?utm_source=coolify.io", + "slogan": "Open-source MQTT broker for IoT, IIoT, and connected vehicles.", + "compose": "c2VydmljZXM6CiAgZW1xeDoKICAgIGltYWdlOiAnZW1xeC9lbXF4LWVudGVycHJpc2U6Ni4yLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FTVFYXzE4MDgzCiAgICAgIC0gJ0VNUVhfREFTSEJPQVJEX19ERUZBVUxUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9FTVFYfScKICAgIHBvcnRzOgogICAgICAtICcxODgzOjE4ODMnCiAgICAgIC0gJzgwODM6ODA4MycKICAgICAgLSAnODA4NDo4MDg0JwogICAgICAtICc4ODgzOjg4ODMnCiAgICB2b2x1bWVzOgogICAgICAtICdlbXF4X2RhdGE6L29wdC9lbXF4L2RhdGEnCiAgICAgIC0gJ2VtcXhfbG9nOi9vcHQvZW1xeC9sb2cnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL29wdC9lbXF4L2Jpbi9lbXF4CiAgICAgICAgLSBjdGwKICAgICAgICAtIHN0YXR1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", + "tags": [ + "mqtt", + "broker", + "iot", + "messaging", + "emqx", + "iiot" + ], + "category": "Networking", + "logo": "svgs/emqx-enterprise.svg", + "minversion": "0.0.0", + "port": "18083" + }, "ente-photos-with-s3": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", @@ -1637,7 +1666,7 @@ "gitea-runner": { "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io", "slogan": "Gitea Actions runner for docker", - "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "gitea", "actions", @@ -1951,7 +1980,7 @@ "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", "slogan": "Grocy is a web-based household management and grocery list application.", - "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfR1JPQ1kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdncm9jeS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", + "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUk9DWQogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyb2N5LWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", "tags": [ "groceries", "household", @@ -1992,6 +2021,25 @@ "logo": "svgs/heimdall.svg", "minversion": "0.0.0" }, + "hermes-agent-with-webui": { + "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", + "slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hFUk1FU1dFQlVJXzg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfSE9TVD0wLjAuMC4wCiAgICAgIC0gSEVSTUVTX1dFQlVJX1BPUlQ9ODc4NwogICAgICAtIEhFUk1FU19XRUJVSV9TVEFURV9ESVI9L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy93ZWJ1aQogICAgICAtIFdBTlRFRF9VSUQ9MTAwMAogICAgICAtIFdBTlRFRF9HSUQ9MTAwMAogICAgICAtICdIRVJNRVNfV0VCVUlfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0hFUk1FU1dFQlVJfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1hZ2VudC1zcmM6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy9oZXJtZXMtYWdlbnQ6cm8nCiAgICAgIC0gJ2hlcm1lcy13b3Jrc3BhY2U6L3dvcmtzcGFjZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4Nzg3L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", + "tags": [ + "ai", + "agent", + "llm", + "chatbot", + "hermes", + "openrouter", + "anthropic", + "openai" + ], + "category": "ai", + "logo": "svgs/hermes-agent.png", + "minversion": "0.0.0", + "port": "8787" + }, "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", @@ -2176,7 +2224,7 @@ "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", - "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSkVMTFlGSU5fODA5NgogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgICAgLSBKRUxMWUZJTl9QdWJsaXNoZWRTZXJ2ZXJVcmw9JFNFUlZJQ0VfVVJMX0pFTExZRklOCiAgICB2b2x1bWVzOgogICAgICAtICdqZWxseWZpbi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnamVsbHlmaW4tdHZzaG93czovZGF0YS90dnNob3dzJwogICAgICAtICdqZWxseWZpbi1tb3ZpZXM6L2RhdGEvbW92aWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwOTYnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46MTAuMTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pFTExZRklOXzgwOTYKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gSkVMTFlGSU5fUHVibGlzaGVkU2VydmVyVXJsPSRTRVJWSUNFX1VSTF9KRUxMWUZJTgogICAgdm9sdW1lczoKICAgICAgLSAnamVsbHlmaW4tY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2plbGx5ZmluLXR2c2hvd3M6L2RhdGEvdHZzaG93cycKICAgICAgLSAnamVsbHlmaW4tbW92aWVzOi9kYXRhL21vdmllcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDk2JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", "tags": [ "media", "server", @@ -2755,7 +2803,7 @@ "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", - "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9NRUFMSUVfOTAwMAogICAgICAtICdBTExPV19TSUdOVVA9JHtBTExPV19TSUdOVVA6LXRydWV9JwogICAgICAtICdQVUlEPSR7UFVJRDotMTAwMH0nCiAgICAgIC0gJ1BHSUQ9JHtQR0lEOi0xMDAwfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ01BWF9XT1JLRVJTPSR7TUFYX1dPUktFUlM6LTF9JwogICAgICAtICdXRUJfQ09OQ1VSUkVOQ1k9JHtXRUJfQ09OQ1VSUkVOQ1k6LTF9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVhbGllX2RhdGE6L2FwcC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvOTAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTozLjE3LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9NRUFMSUVfOTAwMAogICAgICAtICdBTExPV19TSUdOVVA9JHtBTExPV19TSUdOVVA6LXRydWV9JwogICAgICAtICdQVUlEPSR7UFVJRDotMTAwMH0nCiAgICAgIC0gJ1BHSUQ9JHtQR0lEOi0xMDAwfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ01BWF9XT1JLRVJTPSR7TUFYX1dPUktFUlM6LTF9JwogICAgICAtICdXRUJfQ09OQ1VSUkVOQ1k9JHtXRUJfQ09OQ1VSUkVOQ1k6LTF9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVhbGllX2RhdGE6L2FwcC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvOTAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", "tags": [ "recipe manager", "meal planner", @@ -3452,6 +3500,27 @@ "minversion": "0.0.0", "port": "8080" }, + "openobserve": { + "documentation": "https://openobserve.ai/docs/?utm_source=coolify.io", + "slogan": "Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays \u2014 a 140x cheaper alternative to Elasticsearch, Splunk and Datadog.", + "compose": "c2VydmljZXM6CiAgb3Blbm9ic2VydmU6CiAgICBpbWFnZTogJ3B1YmxpYy5lY3IuYXdzL3ppbmNsYWJzL29wZW5vYnNlcnZlOnYwLjkwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PUEVOT0JTRVJWRV81MDgwCiAgICAgIC0gWk9fREFUQV9ESVI9L2RhdGEKICAgICAgLSAnWk9fUk9PVF9VU0VSX0VNQUlMPSR7Wk9fUk9PVF9VU0VSX0VNQUlMOi1yb290QGV4YW1wbGUuY29tfScKICAgICAgLSAnWk9fUk9PVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9PUEVOT0JTRVJWRX0nCiAgICAgIC0gJ1pPX1RFTEVNRVRSWT0ke1pPX1RFTEVNRVRSWTotZmFsc2V9JwogICAgICAtICdaT19DT09LSUVfU0VDVVJFX09OTFk9JHtaT19DT09LSUVfU0VDVVJFX09OTFk6LXRydWV9JwogICAgdm9sdW1lczoKICAgICAgLSAnb3Blbm9ic2VydmUtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvb3Blbm9ic2VydmUKICAgICAgICAtIG5vZGUKICAgICAgICAtIHN0YXR1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMK", + "tags": [ + "logs", + "metrics", + "traces", + "observability", + "monitoring", + "opentelemetry", + "otel", + "elasticsearch", + "splunk", + "datadog" + ], + "category": "monitoring", + "logo": "svgs/openobserve.svg", + "minversion": "0.0.0", + "port": "5080" + }, "openpanel": { "documentation": "https://openpanel.dev/docs?utm_source=coolify.io", "slogan": "Open source alternative to Mixpanel and Plausible for product analytics", @@ -4125,7 +4194,7 @@ "ryot": { "documentation": "https://github.com/ignisda/ryot?utm_source=coolify.io", "slogan": "Roll your own tracker! Ryot is a self-hosted platform for tracking various aspects of life such as media consumption, fitness activities, and more.", - "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnY4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUllPVF84MDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnU0VSVkVSX0FETUlOX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUllPVH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncnlvdF9wb3N0Z3Jlc3FsX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yeW90LWRifScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnYxMC4zLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9SWU9UXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdTRVJWRVJfQURNSU5fQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF82NF9SWU9UfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAwMC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyeW90X3Bvc3RncmVzcWxfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXJ5b3QtZGJ9JwogICAgICAtICdUWj0ke1RaOi1FdXJvcGUvQW1zdGVyZGFtfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "rss", "reader", diff --git a/templates/service-templates.json b/templates/service-templates.json index 206a8cd6e..b07fe0511 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -171,7 +171,7 @@ "audiobookshelf": { "documentation": "https://www.audiobookshelf.org/?utm_source=coolify.io", "slogan": "Self-hosted audiobook, ebook, and podcast server", - "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVVESU9CT09LU0hFTEZfODAKICAgICAgLSAnVFo9JHtUSU1FWk9ORTotQW1lcmljYS9Ub3JvbnRvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLWF1ZGlvYm9va3M6L2F1ZGlvYm9va3MnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLXBvZGNhc3RzOi9wb2RjYXN0cycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLW1ldGFkYXRhOi9tZXRhZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXF1aWV0IC0tdHJpZXM9MSAtLXRpbWVvdXQ9NSBodHRwOi8vbG9jYWxob3N0OjgwL3BpbmcgLU8gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", + "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjoyLjM0LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVVESU9CT09LU0hFTEZfODAKICAgICAgLSAnVFo9JHtUSU1FWk9ORTotQW1lcmljYS9Ub3JvbnRvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLWF1ZGlvYm9va3M6L2F1ZGlvYm9va3MnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLXBvZGNhc3RzOi9wb2RjYXN0cycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLW1ldGFkYXRhOi9tZXRhZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXF1aWV0IC0tdHJpZXM9MSAtLXRpbWVvdXQ9NSBodHRwOi8vbG9jYWxob3N0OjgwL3BpbmcgLU8gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", "tags": [ "audiobooks", "ebooks", @@ -654,6 +654,18 @@ "minversion": "0.0.0", "port": "8978" }, + "cloudflare-ddns": { + "documentation": "https://github.com/favonia/cloudflare-ddns?utm_source=coolify.io", + "slogan": "A small, feature-rich, and robust Cloudflare DDNS updater.", + "compose": "c2VydmljZXM6CiAgY2xvdWRmbGFyZS1kZG5zOgogICAgaW1hZ2U6ICdmYXZvbmlhL2Nsb3VkZmxhcmUtZGRuczoxLjE2LjInCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIHVzZXI6ICcxMDAwOjEwMDAnCiAgICByZWFkX29ubHk6IHRydWUKICAgIGNhcF9kcm9wOgogICAgICAtIGFsbAogICAgc2VjdXJpdHlfb3B0OgogICAgICAtICduby1uZXctcHJpdmlsZWdlczp0cnVlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMT1VERkxBUkVfQVBJX1RPS0VOPSR7Q0xPVURGTEFSRV9BUElfVE9LRU46P30nCiAgICAgIC0gJ0RPTUFJTlM9JHtET01BSU5TOj99JwogICAgICAtICdQUk9YSUVEPSR7UFJPWElFRDotZmFsc2V9JwogICAgICAtICdJUDZfUFJPVklERVI9JHtJUDZfUFJPVklERVI6LW5vbmV9Jwo=", + "tags": [ + "cloud", + "ddns" + ], + "category": "automation", + "logo": "svgs/cloudflare-ddns.svg", + "minversion": "0.0.0" + }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", "slogan": "Client for Cloudflare Tunnel, a daemon that exposes private services through the Cloudflare edge.", @@ -1149,6 +1161,23 @@ "minversion": "0.0.0", "port": "6555" }, + "emqx-enterprise": { + "documentation": "https://www.emqx.io/docs/en/latest/deploy/install-docker.html?utm_source=coolify.io", + "slogan": "Open-source MQTT broker for IoT, IIoT, and connected vehicles.", + "compose": "c2VydmljZXM6CiAgZW1xeDoKICAgIGltYWdlOiAnZW1xeC9lbXF4LWVudGVycHJpc2U6Ni4yLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRU1RWF8xODA4MwogICAgICAtICdFTVFYX0RBU0hCT0FSRF9fREVGQVVMVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRU1RWH0nCiAgICBwb3J0czoKICAgICAgLSAnMTg4MzoxODgzJwogICAgICAtICc4MDgzOjgwODMnCiAgICAgIC0gJzgwODQ6ODA4NCcKICAgICAgLSAnODg4Mzo4ODgzJwogICAgdm9sdW1lczoKICAgICAgLSAnZW1xeF9kYXRhOi9vcHQvZW1xeC9kYXRhJwogICAgICAtICdlbXF4X2xvZzovb3B0L2VtcXgvbG9nJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9vcHQvZW1xeC9iaW4vZW1xeAogICAgICAgIC0gY3RsCiAgICAgICAgLSBzdGF0dXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDMwcwo=", + "tags": [ + "mqtt", + "broker", + "iot", + "messaging", + "emqx", + "iiot" + ], + "category": "Networking", + "logo": "svgs/emqx-enterprise.svg", + "minversion": "0.0.0", + "port": "18083" + }, "ente-photos-with-s3": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", @@ -1637,7 +1666,7 @@ "gitea-runner": { "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io", "slogan": "Gitea Actions runner for docker", - "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "gitea", "actions", @@ -1951,7 +1980,7 @@ "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", "slogan": "Grocy is a web-based household management and grocery list application.", - "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0dST0NZCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgdm9sdW1lczoKICAgICAgLSAnZ3JvY3ktY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JPQ1kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdncm9jeS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", "tags": [ "groceries", "household", @@ -1992,6 +2021,25 @@ "logo": "svgs/heimdall.svg", "minversion": "0.0.0" }, + "hermes-agent-with-webui": { + "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", + "slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IRVJNRVNXRUJVSV84Nzg3CiAgICAgIC0gSEVSTUVTX1dFQlVJX0hPU1Q9MC4wLjAuMAogICAgICAtIEhFUk1FU19XRUJVSV9QT1JUPTg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfU1RBVEVfRElSPS9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvd2VidWkKICAgICAgLSBXQU5URURfVUlEPTEwMDAKICAgICAgLSBXQU5URURfR0lEPTEwMDAKICAgICAgLSAnSEVSTUVTX1dFQlVJX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9IRVJNRVNXRUJVSX0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXJtZXMtaG9tZTovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvaGVybWVzLWFnZW50OnJvJwogICAgICAtICdoZXJtZXMtd29ya3NwYWNlOi93b3Jrc3BhY2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODc4Ny9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "ai", + "agent", + "llm", + "chatbot", + "hermes", + "openrouter", + "anthropic", + "openai" + ], + "category": "ai", + "logo": "svgs/hermes-agent.png", + "minversion": "0.0.0", + "port": "8787" + }, "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", @@ -2176,7 +2224,7 @@ "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", - "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0pFTExZRklOXzgwOTYKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gSkVMTFlGSU5fUHVibGlzaGVkU2VydmVyVXJsPSRTRVJWSUNFX0ZRRE5fSkVMTFlGSU4KICAgIHZvbHVtZXM6CiAgICAgIC0gJ2plbGx5ZmluLWNvbmZpZzovY29uZmlnJwogICAgICAtICdqZWxseWZpbi10dnNob3dzOi9kYXRhL3R2c2hvd3MnCiAgICAgIC0gJ2plbGx5ZmluLW1vdmllczovZGF0YS9tb3ZpZXMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA5NicKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", + "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46MTAuMTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KRUxMWUZJTl84MDk2CiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgICAtIEpFTExZRklOX1B1Ymxpc2hlZFNlcnZlclVybD0kU0VSVklDRV9GUUROX0pFTExZRklOCiAgICB2b2x1bWVzOgogICAgICAtICdqZWxseWZpbi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnamVsbHlmaW4tdHZzaG93czovZGF0YS90dnNob3dzJwogICAgICAtICdqZWxseWZpbi1tb3ZpZXM6L2RhdGEvbW92aWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwOTYnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "media", "server", @@ -2755,7 +2803,7 @@ "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", - "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTUVBTElFXzkwMDAKICAgICAgLSAnQUxMT1dfU0lHTlVQPSR7QUxMT1dfU0lHTlVQOi10cnVlfScKICAgICAgLSAnUFVJRD0ke1BVSUQ6LTEwMDB9JwogICAgICAtICdQR0lEPSR7UEdJRDotMTAwMH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdNQVhfV09SS0VSUz0ke01BWF9XT1JLRVJTOi0xfScKICAgICAgLSAnV0VCX0NPTkNVUlJFTkNZPSR7V0VCX0NPTkNVUlJFTkNZOi0xfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21lYWxpZV9kYXRhOi9hcHAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzkwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTozLjE3LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTUVBTElFXzkwMDAKICAgICAgLSAnQUxMT1dfU0lHTlVQPSR7QUxMT1dfU0lHTlVQOi10cnVlfScKICAgICAgLSAnUFVJRD0ke1BVSUQ6LTEwMDB9JwogICAgICAtICdQR0lEPSR7UEdJRDotMTAwMH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdNQVhfV09SS0VSUz0ke01BWF9XT1JLRVJTOi0xfScKICAgICAgLSAnV0VCX0NPTkNVUlJFTkNZPSR7V0VCX0NPTkNVUlJFTkNZOi0xfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21lYWxpZV9kYXRhOi9hcHAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzkwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "recipe manager", "meal planner", @@ -3452,6 +3500,27 @@ "minversion": "0.0.0", "port": "8080" }, + "openobserve": { + "documentation": "https://openobserve.ai/docs/?utm_source=coolify.io", + "slogan": "Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays \u2014 a 140x cheaper alternative to Elasticsearch, Splunk and Datadog.", + "compose": "c2VydmljZXM6CiAgb3Blbm9ic2VydmU6CiAgICBpbWFnZTogJ3B1YmxpYy5lY3IuYXdzL3ppbmNsYWJzL29wZW5vYnNlcnZlOnYwLjkwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BFTk9CU0VSVkVfNTA4MAogICAgICAtIFpPX0RBVEFfRElSPS9kYXRhCiAgICAgIC0gJ1pPX1JPT1RfVVNFUl9FTUFJTD0ke1pPX1JPT1RfVVNFUl9FTUFJTDotcm9vdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ1pPX1JPT1RfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfT1BFTk9CU0VSVkV9JwogICAgICAtICdaT19URUxFTUVUUlk9JHtaT19URUxFTUVUUlk6LWZhbHNlfScKICAgICAgLSAnWk9fQ09PS0lFX1NFQ1VSRV9PTkxZPSR7Wk9fQ09PS0lFX1NFQ1VSRV9PTkxZOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29wZW5vYnNlcnZlLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL29wZW5vYnNlcnZlCiAgICAgICAgLSBub2RlCiAgICAgICAgLSBzdGF0dXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCg==", + "tags": [ + "logs", + "metrics", + "traces", + "observability", + "monitoring", + "opentelemetry", + "otel", + "elasticsearch", + "splunk", + "datadog" + ], + "category": "monitoring", + "logo": "svgs/openobserve.svg", + "minversion": "0.0.0", + "port": "5080" + }, "openpanel": { "documentation": "https://openpanel.dev/docs?utm_source=coolify.io", "slogan": "Open source alternative to Mixpanel and Plausible for product analytics", @@ -4125,7 +4194,7 @@ "ryot": { "documentation": "https://github.com/ignisda/ryot?utm_source=coolify.io", "slogan": "Roll your own tracker! Ryot is a self-hosted platform for tracking various aspects of life such as media consumption, fitness activities, and more.", - "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnY4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JZT1RfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlc3FsOjU0MzIvJHtQT1NUR1JFU19EQn0nCiAgICAgIC0gJ1NFUlZFUl9BRE1JTl9BQ0NFU1NfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JZT1R9JwogICAgICAtICdUWj0ke1RaOi1FdXJvcGUvQW1zdGVyZGFtfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3J5b3RfcG9zdGdyZXNxbF9kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcnlvdC1kYn0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnYxMC4zLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUllPVF84MDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnU0VSVkVSX0FETUlOX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUllPVH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncnlvdF9wb3N0Z3Jlc3FsX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yeW90LWRifScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "rss", "reader", diff --git a/tests/Feature/SyncBunnyTest.php b/tests/Feature/SyncBunnyTest.php new file mode 100644 index 000000000..8e2b892c6 --- /dev/null +++ b/tests/Feature/SyncBunnyTest.php @@ -0,0 +1,68 @@ + Http::response([], 200), + ]); + + $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid(); + $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log'; + + mkdir($binDir, 0755, true); + + createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH' +#!/bin/sh +printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then + mkdir -p "$4/json/nightly" + printf '{}' > "$4/json/releases.json" + printf '{}' > "$4/json/nightly/versions.json" +fi +exit 0 +SH); + + createFakeSyncBunnyBinary($binDir, 'git', <<<'SH' +#!/bin/sh +printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "status" ]; then + printf 'M %s\n' "$3" +fi +exit 0 +SH); + + $originalPath = getenv('PATH') ?: ''; + putenv("PATH={$binDir}:{$originalPath}"); + putenv("SYNC_BUNNY_TEST_LOG={$logFile}"); + + try { + $this->artisan("sync:bunny {$option} --nightly") + ->expectsConfirmation($confirmation, 'yes') + ->assertExitCode(0); + } finally { + putenv("PATH={$originalPath}"); + putenv('SYNC_BUNNY_TEST_LOG'); + } + + $log = file_get_contents($logFile); + + expect($log) + ->toContain('json/nightly/versions.json') + ->not->toContain('json/versions-nightly.json'); + + if ($syncsReleases) { + expect($log) + ->toContain('json/nightly/releases.json') + ->not->toContain('git add json/releases.json'); + } +})->with([ + 'release sync with releases' => ['--release', 'Are you sure you want to proceed?', true], + 'versions-only github sync' => ['--github-versions', 'Are you sure you want to sync versions.json via GitHub PR?', false], +]); From 8a40c4e348dd87c472c218f0592b1486a09ecb77 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 May 2026 11:51:38 +0200 Subject: [PATCH 067/122] chore(sync-bunny): remove GitHub release sync paths Drop the unused GitHub release and version sync options from sync:bunny, leaving the command focused on BunnyCDN template, release, and nightly syncs. Update the nightly test to assert it does not invoke gh or git. --- app/Console/Commands/SyncBunny.php | 781 +---------------------------- tests/Feature/SyncBunnyTest.php | 60 +-- 2 files changed, 27 insertions(+), 814 deletions(-) diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 50bdfaf1e..d6d77f22e 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; /** * The console command description. @@ -25,651 +25,6 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; - /** - * Fetch GitHub releases and sync to GitHub repository - */ - private function syncReleasesToGitHubRepo(): bool - { - $this->info('Fetching releases from GitHub...'); - try { - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, // Fetch more releases for better changelog - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp; - $branchName = 'update-releases-'.$timestamp; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; - $releasesDir = dirname($releasesPath); - - // Ensure directory exists - if (! is_dir($releasesDir)) { - $this->info("Creating directory: $releasesDir"); - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($releasesPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('Releases are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s'); - $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR Output: '.implode("\n", $output)); - } - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing releases: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync both releases.json and versions.json to GitHub repository in one PR - */ - private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing releases.json and versions.json to GitHub repository...'); - try { - // 1. Fetch releases from GitHub API - $this->info('Fetching releases from GitHub API...'); - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - - // 2. Read versions.json - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $versionsJson = json_decode($file, true); - $actualVersion = data_get($versionsJson, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; - $branchName = 'update-releases-and-versions-'.$timestamp; - $versionsTargetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; - $releasesTargetPath = $nightly ? 'json/nightly/releases.json' : 'json/releases.json'; - - // 3. Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // 4. Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 5. Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/$releasesTargetPath"; - $releasesDir = dirname($releasesPath); - - if (! is_dir($releasesDir)) { - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($releasesPath, $releasesJsonContent) === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 6. Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$versionsTargetPath"; - $versionsDir = dirname($versionsPath); - - if (! is_dir($versionsDir)) { - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($versionsPath, $versionsJsonContent) === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 7. Stage both files - $this->info('Staging changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($releasesTargetPath).' '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 8. Check for changes - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('Both files are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // 9. Commit changes - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 10. Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 11. Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion"; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // 12. Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info("Version synced: $actualVersion"); - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing to GitHub: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync install.sh, docker-compose, and env files to GitHub repository via PR - */ - private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool - { - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("Syncing $envLabel files to GitHub repository..."); - try { - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp; - $branchName = 'update-files-'.$timestamp; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Copy each file to its target path in the CDN repo - $copiedFiles = []; - foreach ($files as $sourceFile => $targetPath) { - if (! file_exists($sourceFile)) { - $this->warn("Source file not found, skipping: $sourceFile"); - - continue; - } - - $destPath = "$tmpDir/$targetPath"; - $destDir = dirname($destPath); - - if (! is_dir($destDir)) { - if (! mkdir($destDir, 0755, true)) { - $this->error("Failed to create directory: $destDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - if (copy($sourceFile, $destPath) === false) { - $this->error("Failed to copy $sourceFile to $destPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $copiedFiles[] = $targetPath; - $this->info("Copied: $targetPath"); - } - - if (empty($copiedFiles)) { - $this->warn('No files were copied. Nothing to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // Stage all copied files - $this->info('Staging changes...'); - $output = []; - $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; - exec($stageCmd, $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Check for changes - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('All files are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // Commit changes - $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); - $fileList = implode("\n- ", $copiedFiles); - $prBody = "Automated update of $envLabel files:\n- $fileList"; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info('Files synced: '.count($copiedFiles)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing files to GitHub: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync versions.json to GitHub repository via PR - */ - private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing versions.json to GitHub repository...'); - try { - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $json = json_decode($file, true); - $actualVersion = data_get($json, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp; - $branchName = 'update-versions-'.$timestamp; - $targetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$targetPath"; - $versionsDir = dirname($versionsPath); - - // Ensure directory exists - if (! is_dir($versionsDir)) { - $this->info("Creating directory: $versionsDir"); - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($versionsPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('versions.json is already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update of $envLabel versions.json to version $actualVersion"; - $output = []; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info("Version synced: $actualVersion"); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing versions.json: '.$e->getMessage()); - - return false; - } - } - /** * Execute the console command. */ @@ -678,8 +33,6 @@ public function handle() $that = $this; $only_template = $this->option('templates'); $only_version = $this->option('release'); - $only_github_releases = $this->option('github-releases'); - $only_github_versions = $this->option('github-versions'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -737,30 +90,11 @@ public function handle() $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { + if (! $only_template && ! $only_version) { $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); + $this->info("About to sync $envLabel files to BunnyCDN."); $this->newLine(); - // Build file mapping for diff - if ($nightly) { - $fileMapping = [ - $compose_file_location => 'docker/nightly/docker-compose.yml', - $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', - $production_env_location => 'environment/nightly/.env.production', - $upgrade_script_location => 'scripts/nightly/upgrade.sh', - $install_script_location => 'scripts/nightly/install.sh', - ]; - } else { - $fileMapping = [ - $compose_file_location => 'docker/docker-compose.yml', - $compose_file_prod_location => 'docker/docker-compose.prod.yml', - $production_env_location => 'environment/.env.production', - $upgrade_script_location => 'scripts/upgrade.sh', - $install_script_location => 'scripts/install.sh', - ]; - } - // BunnyCDN file mapping (local file => CDN URL path) $bunnyFileMapping = [ $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", @@ -813,44 +147,6 @@ public function handle() } } - // Diff against GitHub coolify-cdn repo - $this->newLine(); - $this->info('Fetching coolify-cdn repo to compare...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode); - - if ($returnCode === 0) { - foreach ($fileMapping as $localFile => $cdnPath) { - $remotePath = "$diffTmpDir/repo/$cdnPath"; - if (! file_exists($localFile)) { - continue; - } - if (! file_exists($remotePath)) { - $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)"); - $hasChanges = true; - - continue; - } - - $diffOutput = []; - exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); - if ($diffCode !== 0) { - $hasChanges = true; - $this->newLine(); - $this->info("--- GitHub: $cdnPath"); - $this->info("+++ Local: $cdnPath"); - foreach ($diffOutput as $line) { - if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { - continue; - } - $this->line($line); - } - } - } - } else { - $this->warn('Could not fetch coolify-cdn repo for diff.'); - } - exec('rm -rf '.escapeshellarg($diffTmpDir)); if (! $hasChanges) { @@ -882,9 +178,9 @@ public function handle() return; } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); } $file = file_get_contents($versions_location); $json = json_decode($file, true); @@ -892,8 +188,7 @@ public function handle() $this->info("Version: {$actual_version}"); $this->info('This will:'); - $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); - $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json'); + $this->info(' 1. Sync versions.json to BunnyCDN'); $this->newLine(); $confirmed = confirm('Are you sure you want to proceed?'); @@ -901,8 +196,7 @@ public function handle() return; } - // 1. Sync versions.json to BunnyCDN (deprecated but still needed) - $this->info('Step 1/2: Syncing versions.json to BunnyCDN...'); + $this->info('Syncing versions.json to BunnyCDN...'); Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), @@ -910,46 +204,8 @@ public function handle() $this->info('✓ versions.json uploaded & purged to BunnyCDN'); $this->newLine(); - // 2. Create GitHub PR with both releases.json and versions.json - $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...'); - $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly); - if ($githubSuccess) { - $this->info('✓ GitHub PR created successfully with both files'); - } else { - $this->error('✗ Failed to create GitHub PR'); - } - $this->newLine(); - $this->info('=== Summary ==='); $this->info('BunnyCDN sync: ✓ Complete'); - $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed')); - - return; - } elseif ($only_github_releases) { - $this->info('About to sync GitHub releases to GitHub repository.'); - $confirmed = confirm('Are you sure you want to sync GitHub releases?'); - if (! $confirmed) { - return; - } - - // Sync releases to GitHub repository - $this->syncReleasesToGitHubRepo(); - - return; - } elseif ($only_github_versions) { - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $file = file_get_contents($versions_location); - $json = json_decode($file, true); - $actual_version = data_get($json, 'coolify.v4.version'); - - $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository."); - $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?'); - if (! $confirmed) { - return; - } - - // Sync versions.json to GitHub repository - $this->syncVersionsToGitHubRepo($versions_location, $nightly); return; } @@ -971,31 +227,8 @@ public function handle() $this->info('All files uploaded & purged to BunnyCDN.'); $this->newLine(); - // Sync files to GitHub CDN repository via PR - $this->info('Creating GitHub PR for coolify-cdn repository...'); - if ($nightly) { - $files = [ - $compose_file_location => 'docker/nightly/docker-compose.yml', - $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', - $production_env_location => 'environment/nightly/.env.production', - $upgrade_script_location => 'scripts/nightly/upgrade.sh', - $install_script_location => 'scripts/nightly/install.sh', - ]; - } else { - $files = [ - $compose_file_location => 'docker/docker-compose.yml', - $compose_file_prod_location => 'docker/docker-compose.prod.yml', - $production_env_location => 'environment/.env.production', - $upgrade_script_location => 'scripts/upgrade.sh', - $install_script_location => 'scripts/install.sh', - ]; - } - - $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly); - $this->newLine(); $this->info('=== Summary ==='); $this->info('BunnyCDN sync: Complete'); - $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed')); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/tests/Feature/SyncBunnyTest.php b/tests/Feature/SyncBunnyTest.php index 8e2b892c6..841bb5b5f 100644 --- a/tests/Feature/SyncBunnyTest.php +++ b/tests/Feature/SyncBunnyTest.php @@ -2,67 +2,47 @@ use Illuminate\Support\Facades\Http; -function createFakeSyncBunnyBinary(string $binDir, string $name, string $contents): void +function createSyncBunnyFailingBinary(string $binDir, string $name): void { - file_put_contents("{$binDir}/{$name}", $contents); + file_put_contents("{$binDir}/{$name}", <<<'SH' +#!/bin/sh +printf '%s %s\n' "$(basename "$0")" "$*" >> "$SYNC_BUNNY_TEST_LOG" +exit 1 +SH); chmod("{$binDir}/{$name}", 0755); } -it('syncs nightly files to the nested nightly json path in the cdn repository', function (string $option, string $confirmation, bool $syncsReleases) { +it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () { Http::fake([ - 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200), + 'storage.bunnycdn.com/*' => Http::response([], 201), + 'api.bunny.net/purge*' => Http::response([], 200), ]); $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid(); $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log'; mkdir($binDir, 0755, true); - - createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH' -#!/bin/sh -printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" -if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then - mkdir -p "$4/json/nightly" - printf '{}' > "$4/json/releases.json" - printf '{}' > "$4/json/nightly/versions.json" -fi -exit 0 -SH); - - createFakeSyncBunnyBinary($binDir, 'git', <<<'SH' -#!/bin/sh -printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" -if [ "$1" = "status" ]; then - printf 'M %s\n' "$3" -fi -exit 0 -SH); + createSyncBunnyFailingBinary($binDir, 'gh'); + createSyncBunnyFailingBinary($binDir, 'git'); $originalPath = getenv('PATH') ?: ''; putenv("PATH={$binDir}:{$originalPath}"); putenv("SYNC_BUNNY_TEST_LOG={$logFile}"); try { - $this->artisan("sync:bunny {$option} --nightly") - ->expectsConfirmation($confirmation, 'yes') + $this->artisan('sync:bunny --release --nightly') + ->expectsConfirmation('Are you sure you want to proceed?', 'yes') + ->expectsOutputToContain('BunnyCDN sync: ✓ Complete') + ->doesntExpectOutputToContain('GitHub PR') ->assertExitCode(0); } finally { putenv("PATH={$originalPath}"); putenv('SYNC_BUNNY_TEST_LOG'); } - $log = file_get_contents($logFile); + expect(file_exists($logFile))->toBeFalse(); - expect($log) - ->toContain('json/nightly/versions.json') - ->not->toContain('json/versions-nightly.json'); - - if ($syncsReleases) { - expect($log) - ->toContain('json/nightly/releases.json') - ->not->toContain('git add json/releases.json'); - } -})->with([ - 'release sync with releases' => ['--release', 'Are you sure you want to proceed?', true], - 'versions-only github sync' => ['--github-versions', 'Are you sure you want to sync versions.json via GitHub PR?', false], -]); + Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json'); + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge') + && $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json'); +}); From a22a0c027d80774f9d51465613fa0706ceda1f7b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 May 2026 12:03:30 +0200 Subject: [PATCH 068/122] fix(navbar): align upgrade item with collapsed menu Keep the upgrade action visible while collapsed and apply shared menu icon and label classes so its layout matches other navbar items. Also remove extra logout button spacing. --- resources/views/components/navbar.blade.php | 4 ++-- resources/views/livewire/upgrade.blade.php | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 3b21a81d5..433102dcb 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -368,7 +368,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
@if (isInstanceAdmin() && !isCloud()) @persist('upgrade') -
  • +
  • @endpersist @@ -420,7 +420,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
  • @csrf - -