diff --git a/.ai/lessons.md b/.ai/lessons.md new file mode 100644 index 0000000000..de73b119be --- /dev/null +++ b/.ai/lessons.md @@ -0,0 +1,51 @@ +# Lessons + +## Prove regressions before changing code +- Reproduce the reported failure on the unchanged baseline before adding a fix. +- When a symptom matches an earlier fix, inspect that fix and prove why it no longer works before adding another workaround. +- Test old reports against the current branch because later changes can make the report obsolete. +- Use the same regression test before and after the production change so the result shows the behavior difference. + +## Verify the complete user flow +- Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure. +- Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result. +- When the request covers more than one interface or resource type, inventory and verify each supported path. + +## Preserve product scope +- Do not replace required SPA navigation with a full-page redirect to hide a lifecycle or ordering defect. +- Do not add billing restrictions, live reconciliation, or fallback behavior unless the request includes them. +- Treat implementation constraints as details. Do not expand a requested team-level control into a more complex policy model. + +## Keep dynamic Livewire identities stable +- In dynamic lists, key components and actions with immutable record identities, not counts, indexes, or array positions. +- Use targeted refresh events. Do not refresh a parent and a child that the parent can remove or hide during the same operation. +- Prove lifecycle and redirect causes directly; an effects assertion alone is not sufficient. + +## Keep modal structure consistent +- Identify the parent page that owns a modal trigger and move the complete requested workflow into that modal. +- Use a flat form layout when the modal already supplies its title and description. +- Put destructive actions on the footer's left and primary actions last on the right. +- Use shared section, helper, tooltip, and icon-button components instead of local variants. +- Keep validation, preview, and save controls in a fixed footer when the body is large. + +## Verify layered UI behavior visually +- Inspect the real layout with all conditional elements visible, especially compound status badges. +- For animation flicker, inspect state timing, loading indicators, keyframe fill mode, and focus restoration. +- Add `fill-mode-forwards` to Alpine leave transitions that use tw-animate-css `animate-out` so the element does not flash before Alpine hides it. + +## Preserve inherited values and clear API semantics +- An unchanged displayed default must remain inherited; store an override only when the user selects a different value. +- Expose named API values for special modes. Keep existing numeric sentinels only as compatibility aliases unless a breaking change is requested. + +## Trace infrastructure changes end to end +- For container image changes, inspect Compose services and every relevant Dockerfile build stage. +- Pin a stable release tag instead of using a floating `latest` tag. +- A successful image pull does not prove that the complete application build no longer uses the old image. + +## Make distributed schedules durable +- Use the database as the correctness source for dynamic cron occurrences shared by multiple scheduler and Horizon nodes; Redis locks are load controls, not a durable execution ledger. +- Give each schedule occurrence a unique database identity and make queue consumers claim it atomically before external work. +- Keep pending occurrences recoverable across publisher interruptions, and define an explicit bounded policy for late or offline schedules. + +## Fail closed at public webhook boundaries +- Reject missing or blank secrets before signature verification, and return generic errors without logging secrets, signatures, or payloads. diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index f5d0c3f0ad..de0cf3b8d7 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -1,6 +1,7 @@ name: Coolify Helper Image on: + workflow_dispatch: push: branches: [ "main" ] paths: diff --git a/.github/workflows/coolify-next-build.yml b/.github/workflows/coolify-next-build.yml index 9985edb411..965a98b6bc 100644 --- a/.github/workflows/coolify-next-build.yml +++ b/.github/workflows/coolify-next-build.yml @@ -150,3 +150,8 @@ jobs: --tag "${IMAGE}:sha-${SHA}" \ --tag "${IMAGE}:${VERSION}" \ --tag "${IMAGE}:next" + + - uses: sarisia/actions-status-discord@v1 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/coolify-sha-build.yml b/.github/workflows/coolify-sha-build.yml index 8a1967f743..595fbbd93d 100644 --- a/.github/workflows/coolify-sha-build.yml +++ b/.github/workflows/coolify-sha-build.yml @@ -107,3 +107,8 @@ jobs: "${IMAGE}:sha-${SHA}-amd64" \ "${IMAGE}:sha-${SHA}-aarch64" \ --tag "${IMAGE}:sha-${SHA}" + + - uses: sarisia/actions-status-discord@v1 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 595a21e799..adff8ef82d 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -1,8 +1,8 @@ name: Sync main to next on: - push: - branches: [main] + schedule: + - cron: '*/10 * * * *' workflow_dispatch: permissions: diff --git a/.gitignore b/.gitignore index 5834a7b2c6..3b583795bf 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ CHANGELOG.md /.workspaces /.superpowers/ /docs/superpowers/plans/ +/.ai/todo.md tests/Browser/Screenshots tests/v4/Browser/Screenshots ref diff --git a/AGENTS.md b/AGENTS.md index 4d78dfd938..f9f8ca563a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,11 @@ Because the "server" and the test share one PHP process, they share the phpunit - Custom gates: `createAnyResource`, `canAccessTerminal` - Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods - Multi-tenancy via Teams — team auto-initializes notification settings on creation +- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security. +- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team. +- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data. +- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team. +- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data. ### Event Broadcasting - Soketi WebSocket server for real-time updates (ports 6001-6002 in dev) @@ -191,6 +196,23 @@ Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentin - Exception handler: `app/Exceptions/Handler.php` - Service providers in `app/Providers/` +## Livewire conventions + +### Dynamic lists and snapshot errors + +When an add, delete, or conversion leaves controls unresponsive and the browser reports `Snapshot missing on Livewire component`, inspect both component keys and refresh events. Stable keys alone may not fix it. + +- Give every Livewire component rendered in a loop a stable key based on the record ID, UUID, filename, or another immutable identity. Never include a collection count, `$loop->index`, or a reindexed array position in the key. +- Pass the same stable identity to edit/delete actions. A keyed row can survive reordering while a `wire:ignore` or teleported Alpine modal keeps its original `submitAction`; an action such as `removeItem($index)` then targets a stale position after the first deletion. Resolve the current row server-side from an ID, UUID, or stable row hash instead. +- Do not broadcast one refresh event to both a parent list component and children that the parent may insert, remove, or hide during the same operation. This can queue a child update after its snapshot has been removed from the DOM. +- Split refresh responsibilities into targeted events. Refresh the parent for counts and tab visibility, and refresh an existing child list with a separate event. Use `$this->dispatch('event')->to(Component::class)` instead of a page-wide event when possible. +- Before targeting a child list, confirm that it existed before the mutation, still exists afterward, and is on the active tab. A newly inserted child loads current data during `mount()` and does not need an immediate refresh. A removed or hidden child must not receive one. +- A child that deletes itself should finish its own update, then target only the parent to refresh counts. The parent should not send a refresh back to that child when the list became empty. +- Apply the same pattern to file, directory, conversion, and external reload paths such as Compose edits. One remaining broad event can reproduce the race. +- Add regression tests that assert the scoped event names, assert the old broad event is not dispatched, and verify that keys do not depend on counts or positions. Manually repeat add/delete operations while watching the browser console. + +The persistent-storage implementation is the reference pattern: `Project\Service\Storage` handles `storageCountsChanged`, while `Project\Shared\Storages\All` handles `refreshVolumeList`. + ## Key Conventions - Use `php artisan make:*` commands with `--no-interaction` to create files diff --git a/DESIGN.md b/DESIGN.md index a7b26bb666..53b058ddef 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -34,7 +34,9 @@ The interface is compact and product-focused: - near-neutral layered surfaces instead of large bordered boxes; - 13–14px UI typography and 32px controls; -- hairline rings instead of heavy borders; +- crisp hairline rings plus a restrained card lift (single 1px ring + + `0 1px 2px rgb(0 0 0 / 0.05)`) so cards and tables separate from the canvas, + never heavy borders or a strong floating shadow; - full-width data tables for dense collections; - outline Reicon glyphs through ``; - the Coolify purple brand accent in light mode; @@ -49,6 +51,19 @@ Avoid oversized titles, generic dashboard cards, strong shadows, thick dividers, native browser selects, and isolated colored buttons that do not match the current action styles. +Standard `.button` controls use a compact 2px bottom depth. Hover raises the +button face by 1px and increases the visible depth to 3px. Pressing moves the +face down 2px into the edge and removes the depth until release, keeping the +overall bottom position stable. Disabled controls stay flat, +and focus-visible controls retain the accent ring alongside the depth. +Movement and depth-shadow changes transition over 80ms; color transitions keep +the shared 120ms duration. +Standard button labels use `capitalize`, giving each word an initial capital. +Highlighted buttons mix the accent equally with black for a pronounced bottom +edge, so custom theme colors produce a matching edge instead of a generic one. +Dark mode matches the depth edge of neutral buttons to their regular border +color. Highlighted buttons keep their dark, color-matched accent edge. + --- ## 2. Development and cascade notes @@ -89,14 +104,14 @@ The surface ladder is defined in `resources/css/app.css`. | Token | Light | Dark | Use | |---|---|---|---| -| `--coollabs-canvas` | near white | 10% neutral | page canvas | +| `--coollabs-canvas` | 97% off-white | 10% neutral | page canvas (kept below card fills so cards lift) | | `--coollabs-elevated` | 98% neutral | 15% neutral | shells and card headers | | `--coollabs-base` | white | 17% neutral | nested card bodies | | `--coollabs-recessed` | 96% neutral | 20% neutral | inputs and listboxes | | `--coollabs-fill` | 92.2% neutral | 26.9% neutral | dividers and passive fills | | `--coollabs-line` | translucent dark | 32% neutral | control borders | -| `--coollabs-hairline` | 93.5% neutral | 26.9% neutral | shell rings | -| `--coollabs-subtle` | 55.6% neutral | 70.8% neutral | labels and muted titles | +| `--coollabs-hairline` | 85.5% neutral | 32% neutral | shell rings (crisp enough to read as a card edge, ~1.5:1) | +| `--coollabs-subtle` | 50% neutral | 70.8% neutral | labels and muted titles (light darkened for WCAG AA 4.5:1) | Accent behavior is intentionally theme-aware: @@ -116,6 +131,38 @@ dark:bg-warning/15 dark:text-warning dark:ring-warning/25 The filled top-level action/tab treatment uses the same palette at a restrained opacity rather than a fully saturated fill. +### Shell layering + +The app shell is three distinct surface layers, not one flat color. Chrome +lifts, content is the base, cards lift off the content: + +- **Content canvas** is the base layer: `bg-app` in dark (deepest, + `--color-app` `#0a0a0b`), `bg-gray-50` in light. The `
` content area + and page body use it. +- **Sidebar and topbar chrome** use `bg-panel` in dark (`--color-panel` + `#141418`, a clear step lighter than the content canvas) and `bg-white` in + light, so the chrome reads as a separate panel from the content. + +Dark surface tokens are hex, not oklch. oklch lightness compresses toward pure +black below ~15% (oklch(10%) renders as sRGB 3, oklch(15%) as sRGB 11), so oklch +values there give no visible step between layers. The dark ladder is +`--color-app` 10, `--coollabs-elevated` 22, `--coollabs-base` 28, +`--coollabs-recessed` 34 (sRGB), which reads as distinct surfaces. + +Temperature: every panel is **pure neutral gray** (r=g=b), one consistent +temperature across the sidebar, tables, cards, inputs, dividers, borders, and +text, in both modes. Do not give one surface a cool (blue) or warm cast while +the others stay neutral. The light page canvas uses `bg-neutral-50` (not +`bg-gray-50`, which is faintly cool) so it matches the neutral cards and chrome. +The only intentional color is the purple/yellow brand accent. +- **Cards, tables, and collection tiles** lift off the content canvas with + `dark:bg-white/[0.05]` plus the crisp `--coollabs-hairline` ring; in light + they are `bg-white` with the ring and the restrained card lift. + +Do not paint the content area with the same `bg-panel` as the sidebar, and do +not drop card fills below `dark:bg-white/[0.05]`; both make surfaces read as one +color. Row-hover states keep the lighter `dark:hover:bg-white/[0.025]`. + --- ## 4. Page shells and navigation @@ -419,6 +466,12 @@ The popup panel uses a 10px radius around 6px options with a 4px inset. Keep the option content left-aligned and size the panel to its content or trigger; do not create an unnecessarily wide menu. +Every dropdown, menu, listbox panel, and the command palette uses the shared +`--shadow-dropdown` token (`0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px +rgb(0 0 0 / 0.08)`) for a restrained, consistent lift. Do not hand-roll a +heavier `shadow-lg` / `0 18px 50px` / `0.45`-alpha drop shadow on a menu. +Reserve the stronger `--shadow-modal` for actual modals, dialogs, and toasts. + Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The selected option is indicated inside the menu, not repeated on the trigger. @@ -520,7 +573,10 @@ Do not restore the old full-width footer. Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing -instant-save pattern. +instant-save pattern. When those requests share a component with a modal draft, +pass the unsaved bar a `dirty` Alpine expression comparing that draft with its +initial values, so unrelated saves do not hide pending changes. Mount modal save +bars only while the modal is open to avoid inactive keyboard shortcuts. --- @@ -567,6 +623,15 @@ that hide secondary columns before allowing horizontal overflow. --- +### Domain rows on mobile + +Domain tables become compact summary cards below 600px. Keep the public URL on +its own line, followed by a short routing summary such as `HTTP → HTTPS · Port +80 · Noindex`. Put DNS status and the existing icon actions on the final row. +Do not squeeze desktop label/value columns into a mobile card or move settings +behind an overflow menu. Long domains wrap, and icon actions retain 40px touch +targets. + ## 8. Modals, confirmations, and toasts ### Modals @@ -626,8 +691,9 @@ Current toast behavior: - Reicon status tile for success, info, warning, danger, or default; - title plus optional description; - dismiss and copy-details actions; -- up to four stacked notifications; +- normally up to four stacked notifications, without evicting persistent notices; - four-second dismissal, paused while hovered; +- `persistent: true` disables automatic dismissal, including after hover; users close these notices with the dismiss button; - support for all six screen positions and sanitized custom HTML. Do not bring back the old oversized dark rectangle. diff --git a/README.md b/README.md index ee4028d6a0..33958d34fd 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,66 @@
+Coolify logo + # Coolify -An open-source & self-hostable Heroku / Netlify / Vercel alternative. + +**An open-source platform to deploy applications, databases, and services on your own servers.** + +Open source & free forever, backed by our [philosophy](https://coolify.io/philosophy). ![Latest Release Version](https://img.shields.io/badge/dynamic/json?labelColor=grey&color=6366f1&label=Latest%20released%20version&url=https%3A%2F%2Fcdn.coollabs.io%2Fcoolify%2Fversions.json&query=coolify.v4.version&style=for-the-badge ) + +[Website](https://coolify.io) · [Documentation](https://coolify.io/docs) · [Cloud](https://app.coolify.io) · [Discord](https://coollabs.io/discord) · [Community](https://github.com/coollabsio/coolify/discussions) +
-## About the Project +## What is Coolify? -Coolify is an open-source & self-hostable alternative to Heroku / Netlify / Vercel / etc. +Coolify is an open-source and self-hostable alternative to Heroku, Netlify, and Vercel. It helps you manage servers, applications, and databases on your own hardware. You only need an SSH connection. -It helps you manage your servers, applications, and databases on your own hardware; you only need an SSH connection. You can manage VPS, Bare Metal, Raspberry PIs, and anything else. +You can use a VPS, a bare-metal server, a Raspberry Pi, or any other server that accepts SSH connections. Coolify gives you the convenience of a cloud platform while you keep control of your infrastructure. -Imagine having the ease of a cloud but with your own servers. That is **Coolify**. +Your configurations stay on your servers. If you stop using Coolify, your running resources continue to work and remain manageable. -No vendor lock-in, which means that all the configurations for your applications/databases/etc are saved to your server. So, if you decide to stop using Coolify (oh nooo), you could still manage your running resources. You lose the automations and all the magic. 🪄️ +## What can Coolify do? -For more information, take a look at our landing page at [coolify.io](https://coolify.io). +- **Deploy any application:** Build from GitHub, GitLab, Bitbucket, or Gitea with Nixpacks, Railpack, Dockerfile, Docker Compose, or a prebuilt Docker image. +- **Run databases and services:** Deploy managed databases and more than 300 one-click services with persistent storage and generated credentials. +- **Automate deployments:** Deploy on every Git push, create pull-request previews, call deployment webhooks, and roll back to retained application images. +- **Manage networking:** Configure custom domains, automatic HTTPS certificates, reverse proxies, health checks, and container networks. +- **Operate your infrastructure:** Manage multiple servers, inspect deployment and runtime logs, open container terminals, and monitor resource status. +- **Protect your workloads:** Configure database and storage backups, scheduled tasks, environment variables, secrets, and notifications. +- **Integrate with your workflow:** Manage resources through the dashboard, API, CLI, MCP, and team-based access controls. -## Installation +## Quick start + +Install Coolify on a supported server with one command: ```bash curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash ``` -You can find the installation script source [here](./scripts/install.sh). -> [!NOTE] -> Please refer to the [docs](https://coolify.io/docs/installation) for more information about the installation. +Read the [installation guide](https://coolify.io/docs/installation) for requirements and detailed instructions. You can also review the [installation script](./scripts/install.sh) before you run it. -## Support +## Self-hosted or Cloud -Contact us at [coolify.io/docs/contact](https://coolify.io/docs/contact). +| Self-hosted | Coolify Cloud | +| --- | --- | +| Install Coolify on your own server. | Use a Coolify instance that we maintain. | +| Control and maintain the complete platform. | Get high availability and less maintenance. | +| Free and open source. | Paid service with email notifications and additional support. | -## Cloud +The recommended self-hosted setup uses one server for Coolify and one or more servers for deployed resources. If you do not want to maintain the Coolify server, use [Coolify Cloud](https://app.coolify.io). See [coolify.io](https://coolify.io) for current pricing. -If you do not want to self-host Coolify, there is a paid cloud version available: [app.coolify.io](https://app.coolify.io) +## Community and support -For more information & pricing, take a look at our landing page [coolify.io](https://coolify.io). - -## Why should I use the Cloud version? -The recommended way to use Coolify is to have one server for Coolify and one (or more) for the resources you are deploying. A server is around 4-5$/month. - -By subscribing to the cloud version, you get the Coolify server for the same price, but with: -- High-availability -- Free email notifications -- Better support -- Less maintenance for you +- Read the [documentation](https://coolify.io/docs). +- Join the community on [Discord](https://coollabs.io/discord). +- Ask questions in [GitHub Discussions](https://github.com/coollabsio/coolify/discussions). +- Read the [contribution guide](./CONTRIBUTING.md) before you submit a change. +- Review the [code of conduct](./CODE_OF_CONDUCT.md). +- Contact the team through the [support page](https://coolify.io/docs/contact). ## Donations To stay completely free and open-source, with no feature behind the paywall and evolve the project, we need your help. If you like Coolify, please consider donating to help us fund the project's future development. @@ -57,7 +71,9 @@ Thank you so much! ### Huge Sponsors +* [CubePath](https://cubepath.com/coolify) - Premium dedicated servers and cloud VPS hosting * [Context.dev](https://www.context.dev/) - Web scraping API for AI agents +* [Ginernet](https://ginernet.com/) - Hosting powerful servers in Spain * [SerpAPI](https://serpapi.com) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API. * [MVPS](https://www.mvps.net) - Cheap VPS servers at the highest possible quality * [ScreenshotOne](https://screenshotone.com) - Screenshot API for devs @@ -67,6 +83,7 @@ Thank you so much! ### Big Sponsors +* [Vanaways](https://www.vanaways.co.uk) - New vans for sale and lease across the UK * [Cloudways](https://www.cloudways.com/en/?id=2125302) - Managed cloud hosting platform by DigitalOcean * [ByteBase](https://www.bytebase.com) - Database CI/CD and Security at Scale * [Ramnode](https://ramnode.com/) - High Performance Cloud VPS Hosting @@ -95,7 +112,6 @@ Thank you so much! * [JuxtDigital](https://juxtdigital.com) - Digital PR & AI Authority Building Agency * [SaasyKit](https://saasykit.com) - Complete SaaS starter kit for developers * [American Cloud](https://americancloud.com) - US-based cloud infrastructure services -* [LiquidWeb](https://liquidweb.com) - Premium managed hosting solutions * [Greptile](https://www.greptile.com) - The AI Code Reviewer * [VPSDime](https://vpsdime.com/) - Cheap VPS Hosting - 4GB for $5/month * [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. @@ -105,6 +121,9 @@ Thank you so much! ### Small Sponsors +DarkVPS +Open Source Alternatives +Onserva Movavi ABXY LaunchFast Boilerplates @@ -133,6 +152,8 @@ Thank you so much! Cirun Puls Digital Group Jonathan Pereira +OutboundGateway +T4DT GmbH Internet Garden Evercam Web3 Jobs @@ -149,33 +170,6 @@ Thank you so much! ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) -## Recognitions - -

- - Featured on Hacker News - -

- -Coolify - An open-source & self-hostable Heroku, Netlify alternative | Product Hunt - -coollabsio%2Fcoolify | Trendshift - -## Core Maintainers - -| Andras Bacsai | 🏔️ Peak | -|------------|------------| -| Andras Bacsai | peaklabs-dev | -| | | - -## Repo Activity - -![Alt](https://repobeats.axiom.co/api/embed/eab1c8066f9c59d0ad37b76c23ebb5ccac4278ae.svg "Repobeats analytics image") - ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=coollabsio/coolify&type=Date)](https://star-history.com/#coollabsio/coolify&Date) diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 66ceb95f64..12ac569009 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -13,8 +13,9 @@ class StopApplication public string $jobQueue = 'high'; - public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainers = true): ?string { + $containerPresent = ! $removeContainers; $servers = collect([$application->destination->server]); if ($application?->additional_servers?->count() > 0) { $servers = $servers->merge($application->additional_servers); @@ -26,6 +27,7 @@ class StopApplication } if ($server->isSwarm()) { + $containerPresent = false; instant_remote_process(["docker stack rm {$application->uuid}"], $server); continue; @@ -39,13 +41,15 @@ class StopApplication $timeout = $application->settings->stopGracePeriodSeconds(); foreach ($containersToStop as $containerName) { - instant_remote_process(command: [ - dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", - ], server: $server, throwError: false); + $commands = [dockerStopCommand($timeout, $containerName, $server)]; + if ($removeContainers) { + $commands[] = "docker rm -f $containerName"; + } + + instant_remote_process(command: $commands, server: $server, throwError: false); } - if ($application->build_pack === 'dockercompose') { + if ($removeContainers && $application->build_pack === 'dockercompose') { $application->deleteConnectedNetworks(); } @@ -57,16 +61,22 @@ class StopApplication } } - $status = ['status' => 'exited']; + $status = [ + 'status' => 'exited', + 'container_present' => $containerPresent, + ]; if ($resetRestartCount) { $status = array_merge($status, [ 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); } $application->update($status); ServiceStatusChanged::dispatch($application->environment->project->team->id); + + return null; } } diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php new file mode 100644 index 0000000000..8bb3a3dc08 --- /dev/null +++ b/app/Actions/Application/StopApplicationPreview.php @@ -0,0 +1,34 @@ +application; + $server = $application->destination->server; + $containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id); + + foreach ($containers->pluck('Names') as $containerName) { + $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)]; + if ($removeContainer) { + $commands[] = "docker rm -f $containerName"; + } + instant_remote_process($commands, $server, false); + } + + $preview->update(['status' => 'exited']); + if ($resetRestartCount) { + $preview->resetRestartLimit(); + } + + ServiceStatusChanged::dispatch($application->environment->project->team->id); + } +} diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 3487bc9a42..cb1c517539 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -32,6 +32,11 @@ class StartDatabase if (! $server->isFunctional()) { return 'Server is not functional'; } + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); $activity = activity() ->withProperties([ diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php new file mode 100644 index 0000000000..4b59fe7911 --- /dev/null +++ b/app/Actions/Database/StartDatabaseImport.php @@ -0,0 +1,199 @@ +commands->supports($resource)) { + throw new DatabaseImportException('Database imports are not supported for this database type.'); + } + if (! str($resource->status)->startsWith('running')) { + throw new DatabaseImportException('The database must be running before an import can start.'); + } + + [$server, $container, $network] = $this->target($resource); + $destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination; + if ($destination instanceof SwarmDocker) { + throw new DatabaseImportException('Database imports are not supported for Swarm servers yet.', 501); + } + if (! $server || ! ValidationPatterns::isValidContainerName($container)) { + throw new DatabaseImportException('The database server or container is invalid.', 400); + } + + $lock = Cache::lock(self::lockKey($resource->uuid), self::LOCK_SECONDS); + + if (! $lock->get()) { + throw new DatabaseImportException('A database import is already running.', 409); + } + + try { + return $this->startImport($resource, $source, $teamId, $server, $container, $network); + } finally { + $lock->release(); + } + } + + private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network): Activity + { + $active = Activity::query()->where('properties->team_id', $teamId) + ->where('properties->type_uuid', $resource->uuid) + ->where('properties->operation', 'database_import') + ->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value]) + ->exists(); + if ($active) { + throw new DatabaseImportException('A database import is already running.', 409); + } + + $operation = (string) Str::uuid(); + $containerPath = "/tmp/restore_{$operation}"; + $scriptPath = "/tmp/restore_{$operation}.sh"; + $commandList = []; + $cleanup = ['container' => $container, 'containerTmpPath' => $containerPath, 'scriptPath' => $scriptPath, 'serverId' => $server->id]; + + if ($source->type === 'upload') { + $staged = $source->uploadId + ? "upload/imports/{$teamId}/{$resource->uuid}/{$source->uploadId}/restore" + : "upload/{$resource->uuid}/restore"; + if (! Storage::exists($staged)) { + throw new DatabaseImportException('The completed upload was not found.'); + } + $local = Storage::path($staged); + if ($this->commands->databaseType($resource) === 'postgresql' && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($local)) { + Storage::delete($staged); + throw new DatabaseImportException('The uploaded backup contains disallowed PostgreSQL restore directives.'); + } + $serverPath = "/tmp/database-import-{$operation}"; + instant_scp($local, $serverPath, $server); + $source->uploadId ? Storage::deleteDirectory(dirname($staged)) : Storage::delete($staged); + $commandList[] = 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}"); + $commandList[] = 'rm -f '.escapeshellarg($serverPath); + $cleanup['serverTmpPath'] = $serverPath; + } elseif ($source->type === 'server') { + $this->assertServerPath($source->path); + $size = (int) trim((string) instant_remote_process(['stat -c %s -- '.escapeshellarg($source->path)], $server)); + if ($size < 1 || $size > self::MAX_BYTES) { + throw new DatabaseImportException('The backup file is empty or exceeds the 10 GiB limit.'); + } + $commandList[] = 'docker cp '.escapeshellarg($source->path).' '.escapeshellarg("{$container}:{$containerPath}"); + } else { + $storage = S3Storage::ownedByCurrentTeamAPI($teamId) + ->where(fn ($query) => $query->whereUuid($source->s3StorageUuid)->orWhere('id', ctype_digit((string) $source->s3StorageUuid) ? (int) $source->s3StorageUuid : -1)) + ->where('is_usable', true)->first(); + if (! $storage || ! ValidationPatterns::isValidS3BucketName($storage->bucket)) { + throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.'); + } + $key = ltrim((string) $source->path, '/'); + $this->assertS3Path($key); + $disk = $storage->filesystem(); + if (! $disk->exists($key) || $disk->size($key) > self::MAX_BYTES) { + throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.'); + } + $helper = "s3-restore-{$operation}"; + $serverPath = "/tmp/s3-restore-{$operation}"; + $this->startS3HelperWithEnv($storage, $server, $helper, $network); + $sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}"); + $commandList = [ + 'docker exec '.escapeshellarg($helper).' sh -c '.escapeshellarg('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"'), + 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore', + 'docker cp '.escapeshellarg("{$helper}:/tmp/restore").' '.escapeshellarg($serverPath), + 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}"), + 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', + 'rm -f '.escapeshellarg($serverPath), + ]; + $cleanup += ['containerName' => $helper, 'serverTmpPath' => $serverPath]; + } + + if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) { + $commandList[] = $safety; + } + $restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting)); + $commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath); + $commandList[] = 'chmod +x '.escapeshellarg($scriptPath); + $commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}"); + $commandList[] = 'rm -f '.escapeshellarg($scriptPath); + $commandList[] = 'docker exec '.escapeshellarg($container).' sh -c '.escapeshellarg($scriptPath); + + $activity = remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup); + $activity->properties = $activity->properties->merge(['operation' => 'database_import', 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', 'operation_uuid' => $operation]); + $activity->save(); + + return $activity; + } + + private function target(Model $resource): array + { + if ($resource instanceof ServiceDatabase) { + return [$resource->service?->server, $resource->name.'-'.$resource->service?->uuid, $resource->service?->destination?->network ?? 'coolify']; + } + + return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify']; + } + + private function assertServerPath(?string $path): void + { + if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) { + throw new DatabaseImportException('The server path is invalid.'); + } + } + + private function assertS3Path(string $path): void + { + if ($path === '' || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) { + throw new DatabaseImportException('The S3 path is invalid.'); + } + } + + private function startS3HelperWithEnv(S3Storage $storage, Server $server, string $helper, string $network): void + { + $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion()); + + try { + instant_remote_process([ + 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', + 'docker run -d --network '.escapeshellarg($network) + .' --name '.escapeshellarg($helper) + .' -e S3_ENDPOINT='.escapeshellarg((string) $storage->endpoint) + .' -e S3_ACCESS_KEY='.escapeshellarg((string) $storage->key) + .' -e S3_SECRET_KEY='.escapeshellarg((string) $storage->secret) + .' '.$image.' sleep 3600', + ], $server); + } catch (Throwable) { + instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false); + + throw new DatabaseImportException('Unable to start the S3 restore helper.'); + } + } +} diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index a3a7f16ef0..d3c6fafc4d 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -4,6 +4,7 @@ namespace App\Actions\Database; use App\Actions\Server\CleanupDocker; use App\Events\ServiceStatusChanged; +use App\Models\BaseModel; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -18,7 +19,7 @@ class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string { try { $server = $database->destination->server; @@ -26,15 +27,17 @@ class StopDatabase return 'Server is not functional'; } - $this->stopContainer($database, $database->uuid, 30); + $this->stopContainer($database, $database->uuid, 30, $removeContainer); // Reset restart tracking when database is manually stopped - $database->update([ - 'status' => 'exited', - 'restart_count' => 0, - 'last_restart_at' => null, - 'last_restart_type' => null, - ]); + $database->update(['status' => 'exited']); + if ($resetRestartCount) { + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + } if ($dockerCleanup) { CleanupDocker::dispatch($server, false, false); @@ -53,12 +56,13 @@ class StopDatabase } - private function stopContainer($database, string $containerName, int $timeout = 30): void + private function stopContainer(BaseModel $database, string $containerName, int $timeout = 30, bool $removeContainer = true): void { $server = $database->destination->server; - instant_remote_process(command: [ - dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", - ], server: $server, throwError: false); + $commands = [dockerStopCommand($timeout, $containerName, $server)]; + if ($removeContainer) { + $commands[] = "docker rm -f $containerName"; + } + instant_remote_process(command: $commands, server: $server, throwError: false); } } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 904885dfc5..c69bd1855f 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -3,15 +3,19 @@ namespace App\Actions\Docker; use App\Actions\Application\StopApplication; +use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; +use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; use App\Events\ServiceChecked; +use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceDatabase; use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Services\ContainerStatusAggregator; +use App\Services\RestartCountTracker; use App\Traits\CalculatesExcludedStatus; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -37,8 +41,12 @@ class GetContainersStatus protected ?Collection $applicationContainerRestartCounts; + protected ?Collection $previewContainerRestartCounts; + protected ?Collection $serviceContainerStatuses; + protected ?Collection $serviceContainerRestartCounts; + public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) { $this->containers = $containers; @@ -117,6 +125,9 @@ class GetContainersStatus $containerStatus = "$containerStatus:$healthSuffix"; } $labels = Arr::undot(format_docker_labels_to_json($labels)); + if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { + continue; + } $applicationId = data_get($labels, 'coolify.applicationId'); if ($applicationId) { $pullRequestId = data_get($labels, 'coolify.pullRequestId'); @@ -133,6 +144,12 @@ class GetContainersStatus } else { $preview->update(['last_online_at' => now()]); } + $key = $applicationId.':'.$pullRequestId; + $this->previewContainerRestartCounts ??= collect(); + $this->previewContainerRestartCounts->push([ + 'key' => $key, + 'count' => (int) data_get($container, 'RestartCount', 0), + ]); } else { // Notify user that this container should not be there. } @@ -140,6 +157,9 @@ class GetContainersStatus $application = $this->applications->where('id', $applicationId)->first(); if ($application) { $foundApplications[] = $application->id; + if ($application->container_present !== true) { + $application->update(['container_present' => true]); + } // Store container status for aggregation if (! isset($this->applicationContainerStatuses)) { $this->applicationContainerStatuses = collect(); @@ -220,23 +240,22 @@ class GetContainersStatus // Track restart count for databases (single-container) $restartCount = data_get($container, 'RestartCount', 0); - $previousRestartCount = $database->restart_count ?? 0; - if ($statusFromDb !== $containerStatus) { $updateData = ['status' => $containerStatus]; } else { $updateData = ['last_online_at' => now()]; } - // Update restart tracking if restart count increased - if ($restartCount > $previousRestartCount) { - $updateData['restart_count'] = $restartCount; - $updateData['last_restart_at'] = now(); - $updateData['last_restart_type'] = 'crash'; - } - $database->update($updateData); + if ($restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); + } + if ($isPublic) { $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { if ($this->server->isSwarm()) { @@ -292,6 +311,11 @@ class GetContainersStatus $containerName = data_get($labels, 'com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->serviceContainerRestartCounts ??= collect(); + if (! $this->serviceContainerRestartCounts->has($key)) { + $this->serviceContainerRestartCounts->put($key, collect()); + } + $this->serviceContainerRestartCounts->get($key)->put($containerName, (int) data_get($container, 'RestartCount', 0)); } // Mark service as found @@ -335,46 +359,37 @@ class GetContainersStatus continue; } - $name = data_get($exitedService, 'name'); - $fqdn = data_get($exitedService, 'fqdn'); - if ($name) { - if ($fqdn) { - $containerName = "$name, available at $fqdn"; - } else { - $containerName = $name; - } - } else { - if ($fqdn) { - $containerName = $fqdn; - } else { - $containerName = null; - } + if ($exitedService instanceof ServiceDatabase) { + $exitedService->update(['status' => 'exited']); + } elseif (! $exitedService->stoppedAfterRestartLimit()) { + $exitedService->update([ + 'status' => 'exited', + 'restart_count' => 0, + 'restart_limit_reached' => false, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } - $projectUuid = data_get($service, 'environment.project.uuid'); - $serviceUuid = data_get($service, 'uuid'); - $environmentName = data_get($service, 'environment.name'); - - if ($projectUuid && $serviceUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/service/'.$serviceUuid; - } else { - $url = null; - } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); - $exitedService->update(['status' => 'exited']); } $notRunningApplications = $this->applications->pluck('id')->diff($foundApplications); foreach ($notRunningApplications as $applicationId) { $application = $this->applications->where('id', $applicationId)->first(); - if (str($application->status)->startsWith('exited')) { - continue; - } // Only protection: If no containers at all, Docker query might have failed if ($this->containers->isEmpty()) { continue; } + if (str($application->status)->startsWith('exited')) { + $application->update([ + 'container_present' => false, + 'restart_limit_reached' => false, + ]); + + continue; + } + // If container was recently restarting (crash loop), keep it as degraded for a grace period // This prevents false "exited" status during the brief moment between container removal and recreation $recentlyRestarted = $application->restart_count > 0 && @@ -388,9 +403,11 @@ class GetContainersStatus // Reset restart count when application exits completely $application->update([ 'status' => 'exited', + 'container_present' => false, 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); } } @@ -433,23 +450,10 @@ class GetContainersStatus StopDatabaseProxy::run($database); } - $name = data_get($database, 'name'); - $fqdn = data_get($database, 'fqdn'); - - $containerName = $name; - - $projectUuid = data_get($database, 'environment.project.uuid'); - $environmentName = data_get($database, 'environment.name'); - $databaseUuid = data_get($database, 'uuid'); - - if ($projectUuid && $databaseUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/database/'.$databaseUuid; - } else { - $url = null; - } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } + $this->trackPreviewRestartCounts($previews); + // Aggregate multi-container application statuses if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) { foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { @@ -470,21 +474,21 @@ class GetContainersStatus DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) { $previousRestartCount = $application->restart_count ?? 0; + $restartState = (new RestartCountTracker)->evaluate( + previousRestartCount: $previousRestartCount, + observedRestartCount: $maxRestartCount, + maxRestartCount: $application->max_restart_count ?? 0, + ); - if ($maxRestartCount > $previousRestartCount) { - // Restart count increased - this is a crash restart + if ($restartState['restart_count_changed']) { + $hasCrashRestarts = $restartState['restart_count'] > 0; $application->update([ - 'restart_count' => $maxRestartCount, - 'last_restart_at' => now(), - 'last_restart_type' => 'crash', + 'restart_count' => $restartState['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, ]); - - // Check if restart limit has been reached - $maxAllowedRestarts = $application->max_restart_count ?? 0; - if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) { - $restartLimitReached = true; - } } + $restartLimitReached = $restartState['restart_limit_reached']; // Aggregate status after tracking restart counts $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount); @@ -499,9 +503,22 @@ class GetContainersStatus }); if ($restartLimitReached) { - $application->refresh(); - StopApplication::dispatch($application, false, true, false); - $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + $restartLimitClaimed = Application::query() + ->whereKey($application->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($restartLimitClaimed) { + $application->refresh(); + StopApplication::dispatch( + application: $application, + previewDeployments: false, + dockerCleanup: false, + resetRestartCount: false, + removeContainers: false, + ); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + } } } } @@ -562,6 +579,16 @@ class GetContainersStatus continue; } + $restartCount = isset($this->serviceContainerRestartCounts) + ? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0) + : 0; + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { + StopServiceApplication::dispatch($subResource, false, false); + $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); + + continue; + } + // Parse docker compose from service to check for excluded containers $dockerComposeRaw = data_get($service, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -602,4 +629,24 @@ class GetContainersStatus } } } + + private function trackPreviewRestartCounts(Collection $previews): void + { + if (! isset($this->previewContainerRestartCounts)) { + return; + } + + $this->previewContainerRestartCounts + ->groupBy('key') + ->each(function (Collection $counts, string $key) use ($previews): void { + [$applicationId, $pullRequestId] = explode(':', $key); + $preview = $previews->first(fn (ApplicationPreview $preview): bool => (string) $preview->application_id === $applicationId + && (string) $preview->pull_request_id === $pullRequestId + ); + if ($preview?->trackRestartCount((int) $counts->max('count'))) { + StopApplicationPreview::dispatch($preview, false, false); + $preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview)); + } + }); + } } diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index d437a3a176..c69863c572 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,9 +2,9 @@ namespace App\Actions\Fortify; +use App\Jobs\SendVerificationEmailJob; use App\Models\Team; use App\Models\User; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; @@ -22,8 +22,6 @@ class CreateNewUser implements CreatesNewUsers private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600; - public function __construct(private readonly Request $request) {} - /** * Validate and create a newly registered user. * @@ -77,7 +75,7 @@ class CreateNewUser implements CreatesNewUsers ]); $team = $user->teams()->first(); if (isCloud()) { - $user->sendVerificationEmail(); + SendVerificationEmailJob::dispatch($user); } else { $user->markEmailAsVerified(); } @@ -95,7 +93,7 @@ class CreateNewUser implements CreatesNewUsers { $keys = [ [ - 'key' => 'registration:ip:'.sha1($this->realIp()), + 'key' => 'registration:ip:'.sha1(auth_rate_limit_ip(request())), 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, ], @@ -120,9 +118,4 @@ class CreateNewUser implements CreatesNewUsers RateLimiter::hit($limit['key'], $limit['decay']); } } - - private function realIp(): string - { - return $this->request->server('REMOTE_ADDR') ?? $this->request->ip(); - } } diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php index 320eede0bf..6af8e8d0bb 100644 --- a/app/Actions/Fortify/UpdateUserPassword.php +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -27,5 +27,10 @@ class UpdateUserPassword implements UpdatesUserPasswords $user->fill([ 'password' => Hash::make($input['password']), ])->save(); + auditLog('ui.user.password_changed', [ + 'team_id' => $user->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user->name, + ]); } } diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 76c6c0736f..dd3ff35b6f 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -17,6 +17,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation */ public function update(User $user, array $input): void { + $changedFields = collect(['name', 'email']) + ->filter(fn (string $field): bool => $user->{$field} !== $input[$field]) + ->values() + ->all(); Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], @@ -40,6 +44,15 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation 'email' => $input['email'], ])->save(); } + + if ($changedFields !== []) { + auditLog('ui.user.profile_updated', [ + 'team_id' => $user->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user->name, + 'changed_fields' => $changedFields, + ]); + } } /** diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 99537e606f..47bc002d68 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -3,11 +3,12 @@ namespace App\Actions\Proxy; use App\Enums\ProxyTypes; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; +use App\Services\ProxyPortParser; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Lorisleiva\Actions\Concerns\AsAction; -use Symfony\Component\Yaml\Yaml; class CheckProxy { @@ -52,6 +53,19 @@ class CheckProxy return true; } else { + $portsToCheck = []; + + try { + if ($server->proxyType() !== ProxyTypes::NONE->value) { + $proxyCompose = GetProxyConfiguration::run($server); + $portsToCheck = ProxyPortParser::fromConfiguration($proxyCompose); + } + } catch (\Throwable $e) { + Log::error('Error checking proxy: '.$e->getMessage()); + + return false; + } + $status = getContainerStatus($server, $proxyContainerName); if ($status === 'running') { $server->proxy->set('status', 'running'); @@ -62,37 +76,6 @@ class CheckProxy if ($server->settings->is_cloudflare_tunnel) { return false; } - $ip = $server->ip; - if ($server->id === 0) { - $ip = 'host.docker.internal'; - } - $portsToCheck = []; - - try { - if ($server->proxyType() !== ProxyTypes::NONE->value) { - $proxyCompose = GetProxyConfiguration::run($server); - if (isset($proxyCompose)) { - $yaml = Yaml::parse($proxyCompose); - $configPorts = []; - if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { - $ports = data_get($yaml, 'services.traefik.ports'); - } elseif ($server->proxyType() === ProxyTypes::CADDY->value) { - $ports = data_get($yaml, 'services.caddy.ports'); - } - if (isset($ports)) { - foreach ($ports as $port) { - $configPorts[] = str($port)->before(':')->value(); - } - } - // Combine default ports with config ports - $portsToCheck = array_merge($portsToCheck, $configPorts); - } - } else { - $portsToCheck = []; - } - } catch (\Exception $e) { - Log::error('Error checking proxy: '.$e->getMessage()); - } if (count($portsToCheck) === 0) { return false; } @@ -163,14 +146,31 @@ class CheckProxy /** * Build the SSH command for checking a specific port */ - private function buildPortCheckCommands(Server $server, string $port, string $proxyContainerName): array + private function buildPortCheckCommands(Server $server, int $port, string $proxyContainerName): array { + $portCheckScript = $this->buildPortCheckScript($port, $proxyContainerName); + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); + + return [ + 'ssh_command' => $sshCommand, + 'script' => $portCheckScript, + ]; + } + + private function buildPortCheckScript(int $port, string $proxyContainerName): string + { + + $dockerPortPattern = escapeshellarg('"'.$port.'/tcp"'); + $socketPort = escapeshellarg(':'.$port); + $portSuffixPattern = escapeshellarg(':'.$port.' '); + $portArgument = escapeshellarg((string) $port); + // First check if our own proxy is using this port (which is fine) $getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'"; $checkProxyPortScript = " CONTAINER_ID=\$($getProxyContainerId); if [ ! -z \"\$CONTAINER_ID\" ]; then - if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q '\"$port/tcp\"'; then + if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q $dockerPortPattern; then echo 'proxy_using_port'; exit 0; fi; @@ -183,12 +183,12 @@ class CheckProxy # Try ss command first if command -v ss >/dev/null 2>&1; then - ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null); + ss_output=\$(ss -Htuln state listening sport = $socketPort 2>/dev/null); if [ -z \"\$ss_output\" ]; then echo 'port_free'; exit 0; fi; - count=\$(echo \"\$ss_output\" | grep -c ':$port '); + count=\$(echo \"\$ss_output\" | grep -c $portSuffixPattern); if [ \$count -eq 0 ]; then echo 'port_free'; exit 0; @@ -204,7 +204,7 @@ class CheckProxy # Try netstat as fallback if command -v netstat >/dev/null 2>&1; then - netstat_output=\$(netstat -tuln 2>/dev/null | grep ':$port '); + netstat_output=\$(netstat -tuln 2>/dev/null | grep $portSuffixPattern); if [ -z \"\$netstat_output\" ]; then echo 'port_free'; exit 0; @@ -223,25 +223,20 @@ class CheckProxy fi; # Final fallback using nc - if nc -z -w1 127.0.0.1 $port >/dev/null 2>&1; then + if nc -z -w1 127.0.0.1 $portArgument >/dev/null 2>&1; then echo 'port_conflict|nc_detected'; else echo 'port_free'; fi; "; - $sshCommand = \App\Helpers\SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); - - return [ - 'ssh_command' => $sshCommand, - 'script' => $portCheckScript, - ]; + return $portCheckScript; } /** * Parse the result from port check command */ - private function parsePortCheckResult($processResult, string $port, string $proxyContainerName): bool + private function parsePortCheckResult($processResult, int $port, string $proxyContainerName): bool { $exitCode = $processResult->exitCode(); $output = trim($processResult->output()); @@ -282,15 +277,19 @@ class CheckProxy * Smart port checker that handles dual-stack configurations * Returns true only if there's a real port conflict (not just dual-stack) */ - private function isPortConflict(Server $server, string $port, string $proxyContainerName): bool + private function isPortConflict(Server $server, int $port, string $proxyContainerName): bool { + $dockerPortPattern = escapeshellarg('"'.$port.'/tcp"'); + $socketPort = escapeshellarg(':'.$port); + $portSuffixPattern = escapeshellarg(':'.$port.' '); + // First check if our own proxy is using this port (which is fine) try { $getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'"; $containerId = trim(instant_remote_process([$getProxyContainerId], $server)); if (! empty($containerId)) { - $checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep '\"$port/tcp\"'"; + $checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep $dockerPortPattern"; try { instant_remote_process([$checkProxyPort], $server); @@ -311,9 +310,9 @@ class CheckProxy 'available' => 'command -v ss >/dev/null 2>&1', 'check' => [ // Get listening process details - "ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null) && echo \"\$ss_output\"", + "ss_output=\$(ss -Htuln state listening sport = $socketPort 2>/dev/null) && echo \"\$ss_output\"", // Count IPv4 listeners - "echo \"\$ss_output\" | grep -c ':$port '", + "echo \"\$ss_output\" | grep -c $portSuffixPattern", ], ], // Set 2: Use netstat as alternative to ss @@ -321,9 +320,9 @@ class CheckProxy 'available' => 'command -v netstat >/dev/null 2>&1', 'check' => [ // Get listening process details - "netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep ':$port '", + "netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep $portSuffixPattern", // Count listeners - "echo \"\$netstat_output\" | grep ':$port ' | grep -c 'LISTEN'", + "echo \"\$netstat_output\" | grep $portSuffixPattern | grep -c 'LISTEN'", ], ], // Set 3: Use lsof as last resort @@ -331,9 +330,9 @@ class CheckProxy 'available' => 'command -v lsof >/dev/null 2>&1', 'check' => [ // Get process using the port - "lsof -i :$port -P -n | grep 'LISTEN'", + "lsof -i $socketPort -P -n | grep 'LISTEN'", // Count listeners - "lsof -i :$port -P -n | grep 'LISTEN' | wc -l", + "lsof -i $socketPort -P -n | grep 'LISTEN' | wc -l", ], ], ]; diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index d09aae802a..d910b7b0aa 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -5,6 +5,7 @@ namespace App\Actions\Proxy; use App\Enums\ProxyTypes; use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use App\Services\ProxyPortParser; use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -112,6 +113,7 @@ class GetProxyConfiguration } if (! empty(trim($result ?? ''))) { + ProxyPortParser::fromConfiguration($result); $server->proxy->last_saved_proxy_configuration = $result; $server->save(); diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php index bcfd5011d2..75775c8d6e 100644 --- a/app/Actions/Proxy/SaveProxyConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -3,6 +3,8 @@ namespace App\Actions\Proxy; use App\Models\Server; +use App\Services\ProxyPortParser; +use Illuminate\Validation\ValidationException; use Lorisleiva\Actions\Concerns\AsAction; class SaveProxyConfiguration @@ -13,6 +15,14 @@ class SaveProxyConfiguration public function handle(Server $server, string $configuration): void { + try { + ProxyPortParser::fromConfiguration($configuration); + } catch (\InvalidArgumentException $exception) { + throw ValidationException::withMessages([ + 'configuration' => [$exception->getMessage()], + ]); + } + $proxy_path = $server->proxyPath(); $docker_compose_yml_base64 = base64_encode($configuration); $new_hash = str($docker_compose_yml_base64)->pipe('md5')->value; diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 20c9976564..8ee6c6f242 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -6,6 +6,7 @@ use App\Enums\ProxyTypes; use App\Events\ProxyStatusChanged; use App\Events\ProxyStatusChangedUI; use App\Models\Server; +use App\Services\ProxyPortParser; use Lorisleiva\Actions\Concerns\AsAction; use Spatie\Activitylog\Models\Activity; @@ -19,6 +20,12 @@ class StartProxy if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) { return 'OK'; } + $configuration = GetProxyConfiguration::run($server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + ProxyPortParser::fromConfiguration($configuration); + $server->proxy->set('status', 'starting'); $server->save(); $server->refresh(); @@ -29,10 +36,6 @@ class StartProxy $commands = collect([]); $proxy_path = $server->proxyPath(); - $configuration = GetProxyConfiguration::run($server); - if (! $configuration) { - throw new \Exception('Configuration is not synced'); - } SaveProxyConfiguration::run($server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php new file mode 100644 index 0000000000..b47f03d0b6 --- /dev/null +++ b/app/Actions/Server/ConfigureTrafficAnalytics.php @@ -0,0 +1,38 @@ +settings->is_sentinel_enabled; + + $server->settings->is_traffic_analytics_enabled = $enable; + $server->settings->save(); + $server->refresh(); + + $configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration); + SaveProxyConfiguration::run($server, $configuration); + RestartProxyJob::dispatch($server); + + // Recreate Sentinel so it picks up (enabling) or drops (disabling) the traffic env + proxy-log mount. + // Enabling analytics needs Sentinel running; when disabling, only restart if Sentinel was already + // enabled so we never turn Sentinel on as a side effect of disabling analytics. + if ($enable || $sentinelWasEnabled) { + StartSentinel::run($server, restart: true); + } + } +} diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index cec90288e1..3857d8c4d3 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -10,6 +10,40 @@ class StartSentinel { use AsAction; + public static function trafficLogDirectory(Server $server): string + { + return isDev() + ? '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy' + : rtrim($server->proxyPath(), '/'); + } + + public static function sentinelTrafficEnvironment(Server $server): array + { + if (! $server->isTrafficAnalyticsEnabled()) { + return []; + } + + $logPath = self::trafficLogDirectory($server).'/access.log'; + $settings = $server->settings; + $env = [ + 'TRAFFIC_ENABLED' => 'true', + 'TRAFFIC_PROXY_TYPE' => 'auto', + 'TRAFFIC_ACCESS_LOG_PATH' => $logPath, + 'TRAFFIC_TOPN' => (string) ($settings->traffic_topn ?: 50), + 'TRAFFIC_SAMPLE_THRESHOLD' => (string) ($settings->traffic_sample_threshold ?? 0), + 'TRAFFIC_RETENTION_1H_DAYS' => (string) ($settings->traffic_retention_1h_days ?: 30), + 'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395), + 'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false', + 'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30), + ]; + $license = data_get($settings, 'geoip_maxmind_license_key'); + if ($settings->is_geoip_enabled && filled($license)) { + $env['GEOIP_MAXMIND_LICENSE_KEY'] = $license; + } + + return $env; + } + public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null) { if ($server->isSwarm() || $server->isBuildServer()) { @@ -36,6 +70,7 @@ class StartSentinel 'COLLECTOR_REFRESH_RATE_SECONDS' => $refreshRate, 'COLLECTOR_RETENTION_PERIOD_DAYS' => $metricsHistory, ]; + $environments = array_merge($environments, self::sentinelTrafficEnvironment($server)); $labels = [ 'coolify.managed' => 'true', ]; @@ -48,7 +83,15 @@ class StartSentinel } $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); - $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; + $trafficLogDirectory = self::trafficLogDirectory($server); + $trafficMount = $server->isTrafficAnalyticsEnabled() + ? '-v '.escapeshellarg("{$trafficLogDirectory}:{$trafficLogDirectory}:ro").' ' + : ''; + $network = $server->isLocalhost() ? ' --network coolify' : ''; + $dockerCommand = "docker run -d$network $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; + + $server->sentinelHeartbeat(isReset: true); + $server->forceFill(['sentinel_waiting_since' => now()])->save(); instant_remote_process([ 'docker rm -f coolify-sentinel || true', @@ -60,7 +103,10 @@ class StartSentinel $server->settings->is_sentinel_enabled = true; $server->settings->save(); - $server->sentinelHeartbeat(); + $server->refresh(); + if ($server->sentinel_waiting_since !== null) { + $server->forceFill(['sentinel_waiting_since' => now()])->save(); + } // Dispatch event to notify UI components SentinelRestarted::dispatch($server, $version); diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 463a8ad5bf..3dc5c98b3f 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -24,6 +24,7 @@ class StartService } $service->saveComposeConfigs(); $service->isConfigurationChanged(save: true); + $service->applications()->get()->each->resetRestartLimit(); $workdir = $service->workdir(); // $commands[] = "cd {$workdir}"; $commands[] = "echo 'Saved configuration files to {$workdir}.'"; diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 5e34c8e6a2..52d9edda19 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -49,8 +49,13 @@ class StopService $this->stopContainersInParallel($containersToStop, $server); } - $applications->each->update(['status' => 'exited']); - $dbs->each->update(['status' => 'exited']); + $applications->each(function ($application): void { + $application->update(['status' => 'exited']); + $application->resetRestartLimit(); + }); + $dbs->each(function ($database): void { + $database->update(['status' => 'exited']); + }); if ($deleteConnectedNetworks) { $service->deleteConnectedNetworks(); diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index 184dcb4919..1b53472656 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -13,17 +13,23 @@ class StopServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void + public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $resetRestartCount = true, bool $removeContainer = false): void { $service = $serviceApplication->service; $server = $service->destination->server; $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); - instant_remote_process([ - "docker stop {$containerName}", - ], $server); + if ($removeContainer) { + $commands = ["docker rm -f {$containerName}"]; + } else { + $commands = ["docker stop {$containerName}"]; + } + instant_remote_process($commands, $server, throwError: ! $removeContainer); $serviceApplication->update(['status' => 'exited']); + if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) { + $serviceApplication->resetRestartLimit(); + } ServiceStatusChanged::dispatch($service->environment->project->team->id); } } diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 123b752c0f..7357368f9f 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -56,7 +56,7 @@ class UpdateServiceApplicationFromApi } } - $serviceApplication->fqdn = $parsed['normalized']; + $serviceApplication->setEditableUrls($parsed['normalized']); } if (array_key_exists('noindex_domains', $payload)) { @@ -92,6 +92,11 @@ class UpdateServiceApplicationFromApi $serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN); } + if (array_key_exists('max_restart_count', $payload)) { + $serviceApplication->max_restart_count = $payload['max_restart_count']; + $serviceApplication->restart_limit_reached = false; + } + if (array_key_exists('is_log_drain_enabled', $payload)) { $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); $server = $serviceApplication->service->destination->server; diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php index d0cea0fb1c..2a34c34a1d 100644 --- a/app/Actions/Shared/CheckDomainDns.php +++ b/app/Actions/Shared/CheckDomainDns.php @@ -66,6 +66,7 @@ class CheckDomainDns } $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; + $receivedAddressRecord = false; foreach ($dnsServers as $dnsServer) { $remainingNanoseconds = $deadline - hrtime(true); @@ -90,6 +91,7 @@ class CheckDomainDns continue; } + $receivedAddressRecord = true; if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) { return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); } @@ -99,9 +101,42 @@ class CheckDomainDns } } + if (! $receivedAddressRecord && hrtime(true) < $deadline) { + foreach ($this->resolveWithSystemDns($host, $type) as $resolvedIp) { + if (isCloudflareIp($resolvedIp) || ($expectedIp && $resolvedIp === $expectedIp)) { + return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); + } + } + } + return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); } + /** + * @return array + */ + protected function resolveWithSystemDns(string $host, string $type): array + { + $recordType = $type === DNSTypes::NAME_AAAA ? DNS_AAAA : DNS_A; + $addressKey = $type === DNSTypes::NAME_AAAA ? 'ipv6' : 'ip'; + + try { + $records = @dns_get_record($host, $recordType); + } catch (\Throwable) { + return []; + } + + if (! is_array($records)) { + return []; + } + + return collect($records) + ->pluck($addressKey) + ->filter(fn ($address) => is_string($address) && filter_var($address, FILTER_VALIDATE_IP) !== false) + ->values() + ->all(); + } + private function successMessage(Server $server, ?string $expectedIp): string { if ( diff --git a/app/Actions/Shared/DeleteScheduledVolumeBackup.php b/app/Actions/Shared/DeleteScheduledVolumeBackup.php index 55972ab520..d56cce9384 100644 --- a/app/Actions/Shared/DeleteScheduledVolumeBackup.php +++ b/app/Actions/Shared/DeleteScheduledVolumeBackup.php @@ -12,8 +12,12 @@ class DeleteScheduledVolumeBackup { use AsAction; - public function handle(ScheduledVolumeBackup $backup, ?Server $server = null): void - { + public function handle( + ScheduledVolumeBackup $backup, + ?Server $server = null, + bool $deleteLocalArchives = true, + bool $deleteS3Archives = true, + ): void { $lock = Cache::lock(VolumeBackupJob::lockKey($backup->id), $backup->timeout + 300); if (! $lock->get()) { @@ -30,36 +34,40 @@ class DeleteScheduledVolumeBackup throw new \RuntimeException('Wait for the running storage backup and recovery operations to finish before deleting this schedule.'); } - $localFilenames = $backup->executions() - ->where('local_storage_deleted', false) - ->pluck('filename') - ->filter() - ->all(); + if ($deleteLocalArchives) { + $localFilenames = $backup->executions() + ->where('local_storage_deleted', false) + ->pluck('filename') + ->filter() + ->all(); - if ($localFilenames !== []) { - $server ??= $backup->server(); - if (! $server) { - throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.'); + if ($localFilenames !== []) { + $server ??= $backup->server(); + if (! $server) { + throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.'); + } + + deleteBackupsLocally($localFilenames, $server, throwError: true); } - - deleteBackupsLocally($localFilenames, $server, throwError: true); } - $s3Executions = $backup->executions() - ->with('s3') - ->where('s3_uploaded', true) - ->where('s3_storage_deleted', false) - ->get(); + if ($deleteS3Archives) { + $s3Executions = $backup->executions() + ->with('s3') + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false) + ->get(); - foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { - $s3 = $executions->first()->s3; - if (! $s3) { - throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); - } + foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { + $s3 = $executions->first()->s3; + if (! $s3) { + throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); + } - $filenames = $executions->pluck('filename')->filter()->all(); - if ($filenames !== []) { - deleteBackupsS3($filenames, $s3); + $filenames = $executions->pluck('filename')->filter()->all(); + if ($filenames !== []) { + deleteBackupsS3($filenames, $s3); + } } } diff --git a/app/Actions/Stripe/CreateCheckoutSession.php b/app/Actions/Stripe/CreateCheckoutSession.php new file mode 100644 index 0000000000..42d14c0f25 --- /dev/null +++ b/app/Actions/Stripe/CreateCheckoutSession.php @@ -0,0 +1,221 @@ +stripe ??= app(StripeClient::class); + } + + public static function lockKey(int $teamId): string + { + return "stripe-checkout:team:{$teamId}"; + } + + public function execute(Team $team, User $user, string $priceId): object + { + $lock = Cache::lock(self::lockKey($team->id), 30); + + if (! $lock->get()) { + throw new CheckoutUnavailableException('A subscription checkout is already being created for this team.'); + } + + $previousMaxNetworkRetries = Stripe::getMaxNetworkRetries(); + Stripe::setMaxNetworkRetries(2); + + try { + return $this->createOrReuseSession($team, $user, $priceId); + } finally { + Stripe::setMaxNetworkRetries($previousMaxNetworkRetries); + $lock->release(); + } + } + + private function createOrReuseSession(Team $team, User $user, string $priceId): object + { + $subscription = Subscription::query()->firstOrNew(['team_id' => $team->id]); + $customerId = $subscription->stripe_customer_id; + + if (! $customerId) { + $customer = $this->stripe->customers->create([ + 'email' => $user->email, + 'metadata' => [ + 'team_id' => $team->id, + ], + ], [ + 'idempotency_key' => "coolify-team-{$team->id}-customer", + ]); + $customerId = $customer->id; + $subscription->stripe_customer_id = $customerId; + $subscription->save(); + + Log::info('Stripe customer assigned for subscription checkout.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + ]); + } + + $blockingSubscription = null; + foreach ($this->stripe->subscriptions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'all', + ])->autoPagingIterator() as $stripeSubscription) { + if (in_array($stripeSubscription->status, self::BLOCKING_SUBSCRIPTION_STATUSES, true)) { + $blockingSubscription = $stripeSubscription; + break; + } + } + + $this->throwIfBlockingSubscription($team, $customerId, $blockingSubscription); + + $sessions = $this->stripe->checkout->sessions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'open', + ]); + $subscriptionSessions = collect($sessions->data)->filter( + fn (object $session): bool => ($session->mode ?? null) === 'subscription' + ); + $openSession = $subscriptionSessions->first( + fn (object $session): bool => ($session->status ?? null) === 'open' + ); + + if ($openSession) { + $lineItems = $this->stripe->checkout->sessions->allLineItems($openSession->id); + if (count($lineItems->data) === 1 && data_get($lineItems, 'data.0.price.id') === $priceId) { + Log::info('Reusing pending Stripe subscription checkout.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => $openSession->id, + 'stripe_subscription_id' => $openSession->subscription ?? null, + ]); + + return $openSession; + } + + $this->stripe->checkout->sessions->expire($openSession->id); + } + + $session = $this->stripe->checkout->sessions->create([ + 'allow_promotion_codes' => true, + 'billing_address_collection' => 'required', + 'client_reference_id' => $user->id.':'.$team->id, + 'customer' => $customerId, + 'customer_update' => [ + 'name' => 'auto', + 'address' => 'auto', + ], + 'line_items' => [[ + 'price' => $priceId, + 'adjustable_quantity' => [ + 'enabled' => true, + 'minimum' => 2, + ], + 'quantity' => 2, + ]], + 'tax_id_collection' => [ + 'enabled' => true, + ], + 'automatic_tax' => [ + 'enabled' => true, + ], + 'subscription_data' => [ + 'metadata' => [ + 'user_id' => $user->id, + 'team_id' => $team->id, + ], + ], + 'payment_method_collection' => 'if_required', + 'mode' => 'subscription', + 'expires_at' => now()->addMinutes(35)->timestamp, + 'success_url' => route('dashboard', ['success' => true]), + 'cancel_url' => route('subscription.index', ['cancelled' => true]), + ]); + + Log::info('Stripe subscription checkout created.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => $session->id, + 'stripe_subscription_id' => $session->subscription ?? null, + ]); + + return $session; + } + + private function throwIfBlockingSubscription(Team $team, string $customerId, ?object $blockingSubscription): void + { + if (! $blockingSubscription) { + return; + } + + Log::warning('Stripe subscription checkout blocked by existing subscription.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_subscription_id' => $blockingSubscription->id, + 'stripe_subscription_status' => $blockingSubscription->status, + ]); + + $portalUrl = in_array($blockingSubscription->status, self::RECOVERABLE_SUBSCRIPTION_STATUSES, true) + ? $this->billingPortalUrl($customerId) + : null; + + throw new CheckoutUnavailableException( + $this->blockingSubscriptionMessage($blockingSubscription->status), + $portalUrl, + ); + } + + private function blockingSubscriptionMessage(string $status): string + { + return match ($status) { + 'incomplete' => "This team's subscription payment is incomplete. Complete the payment in the billing portal.", + 'past_due' => "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.", + 'unpaid' => "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal.", + 'paused' => "This team's subscription is paused. Resume it in the billing portal.", + default => 'Team already has an active subscription.', + }; + } + + private function billingPortalUrl(string $customerId): ?string + { + try { + $session = $this->stripe->billingPortal->sessions->create([ + 'customer' => $customerId, + 'return_url' => route('subscription.show'), + ]); + } catch (Throwable) { + return null; + } + + return is_string($session->url ?? null) ? $session->url : null; + } +} diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php index be880b7e78..904460d342 100644 --- a/app/Actions/Team/DeleteTeam.php +++ b/app/Actions/Team/DeleteTeam.php @@ -50,12 +50,22 @@ class DeleteTeam ->get() ->each(function (User $member) use ($team): void { $member->teams()->detach($team); + $member->clearStoredTeamIfMatches($team->id); DB::table('sessions')->where('user_id', $member->id)->delete(); }); + // The deleting owner is excluded from the loop above; clear their + // stored team too so the deleted id is not restored on next login. + $user->clearStoredTeamIfMatches($team->id); + $team->delete(); - return $user->teams()->first(); + // Resolve the next active team the same way login does: the user's + // stored choice when still valid, or their sole remaining team. + // Returns null for a multi-team user whose active team was just + // deleted, so refreshSession sends them to the selection screen + // instead of silently dropping them into an arbitrary first team. + return User::query()->find($user->id)?->resolveStoredTeam(); }); Cache::forget("user:{$user->id}:team:{$team->id}"); diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 165a3ae219..0874970fb6 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -13,7 +13,6 @@ use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; -use App\Models\SslCertificate; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -39,13 +38,14 @@ class CleanupStuckedResources extends Command private function cleanup_stucked_resources() { try { - $teams = Team::all()->filter(function ($team) { - return $team->members()->count() === 0 && $team->servers()->count() === 0; - }); + $teams = Team::query() + ->whereDoesntHave('members') + ->whereDoesntHave('servers') + ->lazyById(); foreach ($teams as $team) { $team->delete(); } - $servers = Server::all()->filter(function ($server) { + $servers = Server::query()->with('team.subscription')->lazyById()->filter(function ($server) { return $server->isFunctional(); }); if (isCloud()) { @@ -60,7 +60,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stucked resources: {$e->getMessage()}\n"; } try { - $servers = Server::onlyTrashed()->get(); + $servers = Server::onlyTrashed()->lazyById(); foreach ($servers as $server) { echo "Force deleting stuck server: {$server->name}\n"; $server->forceDelete(); @@ -69,7 +69,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck servers: {$e->getMessage()}\n"; } try { - $applicationsDeploymentQueue = ApplicationDeploymentQueue::get(); + $applicationsDeploymentQueue = ApplicationDeploymentQueue::query()->lazyById(); foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) { if (is_null($applicationDeploymentQueue->application)) { echo "Deleting stuck application deployment queue: {$applicationDeploymentQueue->id}\n"; @@ -80,7 +80,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application deployment queue: {$e->getMessage()}\n"; } try { - $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); + $applications = Application::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($applications as $application) { echo "Deleting stuck application: {$application->name}\n"; DeleteResourceJob::dispatch($application); @@ -89,18 +89,18 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $applicationsPreviews = ApplicationPreview::get(); + $applicationsPreviews = ApplicationPreview::query() + ->whereDoesntHave('application') + ->lazyById(); foreach ($applicationsPreviews as $applicationPreview) { - if (! data_get($applicationPreview, 'application')) { - echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; - DeleteResourceJob::dispatch($applicationPreview); - } + echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; + DeleteResourceJob::dispatch($applicationPreview); } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get(); + $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($applicationsPreviews as $applicationPreview) { echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n"; DeleteResourceJob::dispatch($applicationPreview); @@ -109,7 +109,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); + $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($postgresqls as $postgresql) { echo "Deleting stuck postgresql: {$postgresql->name}\n"; DeleteResourceJob::dispatch($postgresql); @@ -118,7 +118,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; } try { - $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); + $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($rediss as $redis) { echo "Deleting stuck redis: {$redis->name}\n"; DeleteResourceJob::dispatch($redis); @@ -127,7 +127,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; } try { - $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get(); + $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($keydbs as $keydb) { echo "Deleting stuck keydb: {$keydb->name}\n"; DeleteResourceJob::dispatch($keydb); @@ -136,7 +136,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck keydb: {$e->getMessage()}\n"; } try { - $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get(); + $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($dragonflies as $dragonfly) { echo "Deleting stuck dragonfly: {$dragonfly->name}\n"; DeleteResourceJob::dispatch($dragonfly); @@ -145,7 +145,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n"; } try { - $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get(); + $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($clickhouses as $clickhouse) { echo "Deleting stuck clickhouse: {$clickhouse->name}\n"; DeleteResourceJob::dispatch($clickhouse); @@ -154,7 +154,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n"; } try { - $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); + $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mongodbs as $mongodb) { echo "Deleting stuck mongodb: {$mongodb->name}\n"; DeleteResourceJob::dispatch($mongodb); @@ -163,7 +163,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; } try { - $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); + $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mysqls as $mysql) { echo "Deleting stuck mysql: {$mysql->name}\n"; DeleteResourceJob::dispatch($mysql); @@ -172,7 +172,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; } try { - $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); + $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mariadbs as $mariadb) { echo "Deleting stuck mariadb: {$mariadb->name}\n"; DeleteResourceJob::dispatch($mariadb); @@ -181,7 +181,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; } try { - $services = Service::withTrashed()->whereNotNull('deleted_at')->get(); + $services = Service::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($services as $service) { echo "Deleting stuck service: {$service->name}\n"; DeleteResourceJob::dispatch($service); @@ -190,7 +190,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck service: {$e->getMessage()}\n"; } try { - $serviceApps = ServiceApplication::withTrashed()->whereNotNull('deleted_at')->get(); + $serviceApps = ServiceApplication::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($serviceApps as $serviceApp) { echo "Deleting stuck serviceapp: {$serviceApp->name}\n"; $serviceApp->forceDelete(); @@ -199,7 +199,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } try { - $serviceDbs = ServiceDatabase::withTrashed()->whereNotNull('deleted_at')->get(); + $serviceDbs = ServiceDatabase::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($serviceDbs as $serviceDb) { echo "Deleting stuck serviceapp: {$serviceDb->name}\n"; $serviceDb->forceDelete(); @@ -208,19 +208,27 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } try { - $scheduled_tasks = ScheduledTask::all(); + $scheduled_tasks = ScheduledTask::query() + ->where(function ($query): void { + $query->where(function ($query): void { + $query->whereNull('application_id')->whereNull('service_id'); + })->orWhere(function ($query): void { + $query->whereNotNull('application_id')->whereDoesntHave('application'); + })->orWhere(function ($query): void { + $query->whereNotNull('service_id')->whereDoesntHave('service'); + }); + }) + ->lazyById(); foreach ($scheduled_tasks as $scheduled_task) { - if (! $scheduled_task->service && ! $scheduled_task->application) { - echo "Deleting stuck scheduledtask: {$scheduled_task->name}\n"; - $scheduled_task->delete(); - } + echo "Deleting stuck scheduledtask: {$scheduled_task->name}\n"; + $scheduled_task->delete(); } } catch (\Throwable $e) { echo "Error in cleaning stuck scheduledtasks: {$e->getMessage()}\n"; } try { - $scheduled_backups = ScheduledDatabaseBackup::all(); + $scheduled_backups = ScheduledDatabaseBackup::query()->lazyById(); foreach ($scheduled_backups as $scheduled_backup) { try { $server = $scheduled_backup->server(); @@ -238,7 +246,7 @@ class CleanupStuckedResources extends Command // Cleanup any resources that are not attached to any environment or destination or server try { - $applications = Application::all(); + $applications = Application::query()->lazyById(); foreach ($applications as $application) { if (! data_get($application, 'environment')) { echo 'Application without environment: '.$application->name.'\n'; @@ -263,7 +271,7 @@ class CleanupStuckedResources extends Command echo "Error in application: {$e->getMessage()}\n"; } try { - $postgresqls = StandalonePostgresql::all()->where('id', '!=', 0); + $postgresqls = StandalonePostgresql::query()->where('id', '!=', 0)->lazyById(); foreach ($postgresqls as $postgresql) { if (! data_get($postgresql, 'environment')) { echo 'Postgresql without environment: '.$postgresql->name.'\n'; @@ -288,7 +296,7 @@ class CleanupStuckedResources extends Command echo "Error in postgresql: {$e->getMessage()}\n"; } try { - $redis = StandaloneRedis::all(); + $redis = StandaloneRedis::query()->lazyById(); foreach ($redis as $redis) { if (! data_get($redis, 'environment')) { echo 'Redis without environment: '.$redis->name.'\n'; @@ -314,7 +322,7 @@ class CleanupStuckedResources extends Command } try { - $mongodbs = StandaloneMongodb::all(); + $mongodbs = StandaloneMongodb::query()->lazyById(); foreach ($mongodbs as $mongodb) { if (! data_get($mongodb, 'environment')) { echo 'Mongodb without environment: '.$mongodb->name.'\n'; @@ -340,7 +348,7 @@ class CleanupStuckedResources extends Command } try { - $mysqls = StandaloneMysql::all(); + $mysqls = StandaloneMysql::query()->lazyById(); foreach ($mysqls as $mysql) { if (! data_get($mysql, 'environment')) { echo 'Mysql without environment: '.$mysql->name.'\n'; @@ -366,7 +374,7 @@ class CleanupStuckedResources extends Command } try { - $mariadbs = StandaloneMariadb::all(); + $mariadbs = StandaloneMariadb::query()->lazyById(); foreach ($mariadbs as $mariadb) { if (! data_get($mariadb, 'environment')) { echo 'Mariadb without environment: '.$mariadb->name.'\n'; @@ -392,7 +400,7 @@ class CleanupStuckedResources extends Command } try { - $services = Service::all(); + $services = Service::query()->lazyById(); foreach ($services as $service) { if (! data_get($service, 'environment')) { echo 'Service without environment: '.$service->name.'\n'; @@ -417,43 +425,23 @@ class CleanupStuckedResources extends Command echo "Error in service: {$e->getMessage()}\n"; } try { - $serviceApplications = ServiceApplication::all(); + $serviceApplications = ServiceApplication::query()->whereDoesntHave('service')->lazyById(); foreach ($serviceApplications as $service) { - if (! data_get($service, 'service')) { - echo 'ServiceApplication without service: '.$service->name.'\n'; - $service->forceDelete(); - - continue; - } + echo 'ServiceApplication without service: '.$service->name.'\n'; + $service->forceDelete(); } } catch (\Throwable $e) { echo "Error in serviceApplications: {$e->getMessage()}\n"; } try { - $serviceDatabases = ServiceDatabase::all(); + $serviceDatabases = ServiceDatabase::query()->whereDoesntHave('service')->lazyById(); foreach ($serviceDatabases as $service) { - if (! data_get($service, 'service')) { - echo 'ServiceDatabase without service: '.$service->name.'\n'; - $service->forceDelete(); - - continue; - } + echo 'ServiceDatabase without service: '.$service->name.'\n'; + $service->forceDelete(); } } catch (\Throwable $e) { echo "Error in ServiceDatabases: {$e->getMessage()}\n"; } - try { - $orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) { - $query->select('id')->from('servers'); - })->get(); - - foreach ($orphanedCerts as $cert) { - echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n"; - $cert->delete(); - } - } catch (\Throwable $e) { - echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n"; - } } } diff --git a/app/Console/Commands/ScheduledJobDiagnostics.php b/app/Console/Commands/ScheduledJobDiagnostics.php index 77881284cb..61f26265a9 100644 --- a/app/Console/Commands/ScheduledJobDiagnostics.php +++ b/app/Console/Commands/ScheduledJobDiagnostics.php @@ -9,6 +9,7 @@ use App\Models\Server; use App\Models\Team; use Illuminate\Console\Command; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; class ScheduledJobDiagnostics extends Command @@ -203,7 +204,6 @@ class ScheduledJobDiagnostics extends Command } $dedupKeys = [ - "sentinel-restart:{$server->id}" => '0 0 * * *', "server-patch-check:{$server->id}" => '0 0 * * 0', "server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *', "server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'), @@ -235,7 +235,7 @@ class ScheduledJobDiagnostics extends Command $this->newLine(); } - private function getServers(?string $serverFilter): \Illuminate\Support\Collection + private function getServers(?string $serverFilter): Collection { $query = Server::with('settings')->where('ip', '!=', '1.2.3.4'); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6dc323838..d5b0e5a9c5 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,7 +5,7 @@ namespace App\Console; use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; -use App\Jobs\CheckTraefikVersionJob; +use App\Jobs\CheckMissingDatabaseBackupsJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\CleanupStaleMultiplexedConnections; @@ -16,6 +16,7 @@ use App\Jobs\ScheduledJobManager; use App\Jobs\ServerManagerJob; use App\Jobs\UpdateCoolifyJob; use App\Models\InstanceSettings; +use App\Services\ScheduledJobDeliveryService; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -46,8 +47,18 @@ class Kernel extends ConsoleKernel ->hourly() ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); + $this->scheduleInstance->call(fn () => app(ScheduledJobDeliveryService::class)->deleteOldOccurrences()) + ->name('cleanup:scheduled-job-occurrences') + ->dailyAt('04:00') + ->onOneServer(); + $this->scheduleInstance->command('cleanup:stucked-resources') + ->dailyAt('03:17') + ->onOneServer() + ->withoutOverlapping(60) + ->runInBackground(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); + $this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer(); if (isDev()) { // Instance Jobs @@ -84,8 +95,6 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer(); - $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); - $this->scheduleInstance->command('cleanup:database --yes')->daily(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); diff --git a/app/Data/Traffic/TrafficBreakdownData.php b/app/Data/Traffic/TrafficBreakdownData.php new file mode 100644 index 0000000000..a98e5323a4 --- /dev/null +++ b/app/Data/Traffic/TrafficBreakdownData.php @@ -0,0 +1,23 @@ +teamId}")]; + } +} diff --git a/app/Events/SentinelSynchronized.php b/app/Events/SentinelSynchronized.php new file mode 100644 index 0000000000..3c39e46f8d --- /dev/null +++ b/app/Events/SentinelSynchronized.php @@ -0,0 +1,36 @@ +teamId = $server->team_id; + $this->serverUuid = $server->uuid; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Exceptions/CheckoutUnavailableException.php b/app/Exceptions/CheckoutUnavailableException.php new file mode 100644 index 0000000000..329941dbc6 --- /dev/null +++ b/app/Exceptions/CheckoutUnavailableException.php @@ -0,0 +1,18 @@ +routeIs('login.store', 'two-factor.login.store') && $request->user()) { + return redirect()->intended(RouteServiceProvider::HOME); + } + // Handle authorization exceptions for API routes. Exceptions carrying // an explicit status (e.g. denyAsNotFound) keep it via parent::render. if ($e instanceof AuthorizationException && ! $e->hasStatus()) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 784751caf0..2ceed613dc 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; +use App\Models\ApplicationSetting; use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\LocalFileVolume; @@ -23,9 +24,11 @@ use App\Rules\DockerImageFormat; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -59,6 +62,7 @@ class ApplicationsController extends Controller 'gpu_options', 'is_consistent_container_name_enabled', 'custom_internal_name', + 'custom_container_name_prefix', ]; private const BOOLEAN_APPLICATION_SETTING_FIELDS = [ @@ -151,9 +155,26 @@ class ApplicationsController extends Controller : $request->input($field); } + if (array_key_exists('custom_container_name_prefix', $settings)) { + $settings['custom_container_name_prefix'] = str($settings['custom_container_name_prefix'])->slug()->value() ?: null; + } + return $settings; } + private function containerNamePrefixValidationResponse(array $settings, Server $server, ?Application $application = null): ?JsonResponse + { + $prefix = $settings['custom_container_name_prefix'] ?? null; + if (! filled($prefix) || ! ApplicationSetting::isContainerNamePrefixInUse($prefix, $server, $application?->id)) { + return null; + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['custom_container_name_prefix' => ['This container name prefix is already in use by another application.']], + ], 422); + } + private function applyApplicationSettings(Application $application, array $settings): void { if ($settings === []) { @@ -391,6 +412,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -585,6 +607,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -779,6 +802,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -944,6 +968,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -1105,6 +1130,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -1333,6 +1359,9 @@ class ApplicationsController extends Controller ], 422); } } + if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $destination->server)) { + return $prefixValidation; + } if ($type === 'public') { $validationRules = [ 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], @@ -1454,9 +1483,17 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } - $repository_url_parsed = Url::fromString($request->git_repository); + $gitRepository = $application->git_repository; + $httpsRepository = scpStyleGitUrlToHttps($gitRepository); + if (is_string($httpsRepository)) { + $gitRepository = $httpsRepository; + $application->git_repository = $httpsRepository; + } + $repository_url_parsed = Url::fromString($gitRepository); $git_host = $repository_url_parsed->getHost(); if ($git_host === 'github.com') { $application->source_type = GithubApp::class; @@ -1618,11 +1655,7 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Failed to generate Github App token.'], 400); } - $gitRepository = $request->git_repository; - if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) { - $gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', ''); - } - $gitRepository = str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString(); + $gitRepository = gitRepositorySlug($request->git_repository); // Use direct API call to verify repository access instead of loading all repositories // This is much faster and avoids timeouts for GitHub Apps with many repositories @@ -1718,7 +1751,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1949,7 +1984,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; @@ -2400,13 +2437,12 @@ class ApplicationsController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', @@ -2446,6 +2482,59 @@ class ApplicationsController extends Controller ), ] )] + #[OA\Get( + summary: 'Get preview application logs.', + description: 'Get runtime container logs for a preview deployment by application UUID and pull request ID.', + path: '/applications/{uuid}/previews/{pull_request_id}/logs', + operationId: 'get-preview-application-logs-by-pull-request-id', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string'), + ), + new OA\Parameter( + name: 'pull_request_id', + in: 'path', + description: 'Pull request ID of the preview deployment.', + required: true, + schema: new OA\Schema(type: 'integer', minimum: 1), + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', + required: false, + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) + ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Preview runtime logs.', content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'logs', type: 'string')], + )), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] public function logs_by_uuid(Request $request) { $teamId = getTeamIdFromToken(); @@ -2461,7 +2550,25 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Application not found.'], 404); } - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id); + $this->authorize('view', $application); + + $pullRequestId = null; + $pullRequestIdRaw = $request->route('pull_request_id'); + if ($pullRequestIdRaw !== null) { + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + $pullRequestId = (int) $pullRequestIdRaw; + + $previewExists = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pullRequestId) + ->exists(); + if (! $previewExists) { + return response()->json(['message' => 'Preview not found.'], 404); + } + } + + $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pullRequestId); if ($containers->count() == 0) { return response()->json([ @@ -2487,6 +2594,256 @@ class ApplicationsController extends Controller ]); } + #[OA\Patch( + summary: 'Update Preview Domains', + description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.', + path: '/applications/{uuid}/previews/{pull_request_id}', + operationId: 'update-preview-domains-by-pull-request-id', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'), + new OA\Property( + property: 'docker_compose_domains', + type: 'array', + nullable: true, + items: new OA\Items(properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'domain', type: 'string', nullable: true), + new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']), + ], type: 'object'), + ), + new OA\Property(property: 'force_domain_override', type: 'boolean', default: false), + ], + )), + responses: [ + new OA\Response(response: 200, description: 'Preview domains updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, ref: '#/components/responses/403'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Domain conflict.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update_preview_by_pull_request_id(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $pullRequestIdRaw = $request->route('pull_request_id'); + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', (int) $pullRequestIdRaw) + ->first(); + if (! $preview) { + return response()->json(['message' => 'Preview not found.'], 404); + } + + $isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value; + $validationRules = ['force_domain_override' => 'boolean']; + if ($isCompose) { + $validationRules = array_merge($validationRules, [ + 'domains' => 'missing', + 'docker_compose_domains' => 'present|array', + 'docker_compose_domains.*' => 'array:name,domain,redirect', + 'docker_compose_domains.*.name' => 'required|string|distinct', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', + ]); + } else { + $validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()]; + $validationRules['docker_compose_domains'] = 'missing'; + } + + $validator = Validator::make($request->all(), $validationRules); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $dockerComposeDomains = null; + $dockerComposeDomainsResponse = null; + if ($isCompose) { + try { + $compose = Yaml::parse($application->docker_compose_raw ?? ''); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'], + ], 422); + } + + $services = data_get($compose, 'services'); + if (! is_array($services) || $services === []) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'], + ], 422); + } + + $composeServices = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->keys() + ->map(fn (mixed $name): string => (string) $name) + ->values(); + $requestedServices = collect($request->input('docker_compose_domains'))->pluck('name'); + if ($requestedServices->diff($composeServices)->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'], + ], 422); + } + + $existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: []; + $dockerComposeDomains = $composeServices + ->mapWithKeys(function (string $service) use ($existingComposeDomains): array { + $entry = ['domain' => '']; + $redirect = $existingComposeDomains[$service]['redirect'] ?? null; + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + + return [$service => $entry]; + }) + ->all(); + foreach ($request->input('docker_compose_domains') as $item) { + $entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? '']; + $redirect = array_key_exists('redirect', $item) + ? data_get($item, 'redirect') + : ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomains[data_get($item, 'name')] = $entry; + } + $domains = collect($dockerComposeDomains) + ->pluck('domain') + ->filter() + ->implode(',') ?: null; + } else { + $domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains')); + } + + $submittedUrls = collect(ValidationPatterns::applicationDomainList($domains)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)); + if ($submittedUrls->duplicates()->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + $isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.', + ], + ], 422); + } + + $normalized = DomainPortOverrides::normalize($domains, null); + $portlessDomains = $normalized['fqdn']; + if ($isCompose) { + foreach ($dockerComposeDomains as $service => $entry) { + $dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'])) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + } + $dockerComposeDomainsResponse = collect($dockerComposeDomains) + ->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry]) + ->values() + ->all(); + } + $urls = collect(ValidationPatterns::applicationDomainList($portlessDomains)); + $conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId); + if (isset($conflicts['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']], + ], 422); + } + if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $conflicts['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $hostCandidates = $urls + ->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST)) + ->filter(); + $conflictingPreview = null; + if ($hostCandidates->isNotEmpty()) { + $conflictingPreview = ApplicationPreview::query() + ->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId) + ->withoutGlobalScope('withRelations') + ->reorder() + ->select('applications.id')) + ->whereKeyNot($preview->id) + ->whereNotNull('fqdn') + ->where(function ($query) use ($hostCandidates): void { + foreach ($hostCandidates as $host) { + $query->orWhere('fqdn', 'like', '%'.$host.'%'); + } + }) + ->get(['uuid', 'pull_request_id', 'fqdn']) + ->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->isNotEmpty()); + } + + if ($conflictingPreview && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => [[ + 'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->first(), + 'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id, + 'resource_uuid' => $conflictingPreview->uuid, + 'resource_type' => 'application', + 'message' => 'Domain is already in use by another preview deployment.', + ]], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $preview->domain_port_overrides = $normalized['overrides']; + $preview->fqdn = $portlessDomains; + if ($isCompose) { + $preview->docker_compose_domains = json_encode($dockerComposeDomains); + } + $preview->save(); + + auditLog('api.application.preview_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'], + ]); + + return response()->json([ + 'uuid' => $preview->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'domains' => $preview->fqdn, + 'docker_compose_domains' => $dockerComposeDomainsResponse, + 'domain_port_overrides' => $preview->domain_port_overrides, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete application by UUID.', @@ -2560,6 +2917,8 @@ class ApplicationsController extends Controller $this->authorize('delete', $application); + $application->delete(); + DeleteResourceJob::dispatch( resource: $application, deleteVolumes: $request->boolean('delete_volumes', true), @@ -2707,6 +3066,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], @@ -2816,6 +3176,7 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => 'string', 'http_basic_auth_password' => 'string', 'include_source_commit_in_build' => 'boolean', + 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ @@ -2824,10 +3185,10 @@ class ApplicationsController extends Controller $validator = Validator::make($request->all(), $validationRules, $validationMessages); // Validate ports_exposes - if ($request->has('ports_exposes')) { + if ($request->filled('ports_exposes')) { $ports = explode(',', $request->ports_exposes); foreach ($ports as $port) { - if (! is_numeric($port)) { + if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -2879,6 +3240,9 @@ class ApplicationsController extends Controller } $applicationSettings = $this->applicationSettingsFromRequest($request); + if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { + return $prefixValidation; + } $requestedBuildPack = $request->input('build_pack', $application->build_pack); if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') { return response()->json([ @@ -3115,7 +3479,12 @@ class ApplicationsController extends Controller } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts( + $dockerComposeDomainsJson, + $application->domain_port_overrides, + ); data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); + data_set($data, 'domain_port_overrides', $domainPortOverrides); } $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); @@ -4605,7 +4974,6 @@ class ApplicationsController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], additionalProperties: false, @@ -4667,11 +5035,10 @@ class ApplicationsController extends Controller 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); - $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'content']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4713,7 +5080,7 @@ class ApplicationsController extends Controller } $isReadOnly = $storage->shouldBeReadOnlyInUI(); - $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $editableOnlyFields = ['name', 'mount_path', 'content']; $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); if ($isReadOnly && ! empty($requestedEditableFields)) { @@ -4752,9 +5119,6 @@ class ApplicationsController extends Controller if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; } - if ($request->has('host_path')) { - $storage->host_path = $request->host_path; - } } else { if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; @@ -4809,7 +5173,6 @@ class ApplicationsController extends Controller 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], @@ -4854,14 +5217,13 @@ class ApplicationsController extends Controller 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; + $allAllowedFields = ['type', 'name', 'mount_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4897,7 +5259,6 @@ class ApplicationsController extends Controller $storage = LocalPersistentVolume::create([ 'name' => $application->uuid.'-'.$request->name, 'mount_path' => $request->mount_path, - 'host_path' => $request->host_path, 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]); @@ -5150,7 +5511,7 @@ class ApplicationsController extends Controller $this->authorize('delete', $application); $pullRequestIdRaw = $request->route('pull_request_id'); - if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { return response()->json(['message' => 'Invalid pull_request_id.'], 422); } $pullRequestId = (int) $pullRequestIdRaw; @@ -5854,4 +6215,28 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Destination detached.']); } + + /** + * @param Collection $domains + * @param array|null $existingOverrides + * @return array{Collection, ?array} + */ + private function normalizeDockerComposeDomainPorts(Collection $domains, ?array $existingOverrides = null): array + { + $allDomains = $domains + ->pluck('domain') + ->filter() + ->implode(','); + $normalized = DomainPortOverrides::normalize($allDomains, $existingOverrides); + + $domains = $domains->map(function (array $entry): array { + $entry['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'] ?? null)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + + return $entry; + }); + + return [$domains, $normalized['overrides']]; + } } diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php index da452bb303..63dcca6982 100644 --- a/app/Http/Controllers/Api/AuditEventsController.php +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -48,6 +48,7 @@ class AuditEventsController extends Controller 'event', 'source', 'action', + 'level', 'actor_type', 'actor_id', 'actor_name', diff --git a/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php new file mode 100644 index 0000000000..a2ec2b93be --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php @@ -0,0 +1,125 @@ +authorize('uploadBackup', $resource); + $validator = Validator::make($request->all(), ['upload_id' => ['required', 'uuid'], 'file' => ['required', 'file']]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + $originalName = $request->file('file')?->getClientOriginalName(); + if (! $originalName || ! DatabaseBackupFileValidator::hasAllowedExtension($originalName)) { + return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Unsupported backup file extension.']]], 422); + } + if ((int) $request->input('dzTotalFilesize', 0) > StartDatabaseImport::MAX_BYTES) { + return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['The backup exceeds the 10 GiB limit.']]], 422); + } + + $request->merge(['dzuuid' => $request->input('dzuuid', $request->string('upload_id')->value())]); + $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); + $save = $receiver->receive(); + if (! $save->isFinished()) { + return response()->json(['upload_id' => $request->string('upload_id')->value(), 'done' => $save->handler()->getPercentageDone(), 'status' => true]); + } + + $file = $save->getFile(); + if (! $file instanceof UploadedFile || ! DatabaseBackupFileValidator::isUploadAllowed($file, StartDatabaseImport::MAX_BYTES)) { + @unlink($file->getPathname()); + + return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Uploaded file failed validation.']]], 422); + } + $mimeType = $file->getMimeType(); + $size = $file->getSize(); + $directory = "upload/imports/{$teamId}/{$resource->uuid}/{$request->string('upload_id')->value()}"; + Storage::makeDirectory($directory); + $file->move(Storage::path($directory), 'restore'); + + return response()->json(['upload_id' => $request->string('upload_id')->value(), 'filename' => $originalName, 'mime_type' => $mimeType, 'size' => $size], 201); + } + + protected function startDatabaseImport(Request $request, Model $resource, int $teamId, string $statusRoute, array $routeParameters): JsonResponse + { + $this->authorize('update', $resource); + $payload = $request->json()->all() ?: $request->request->all(); + $allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing']; + $validator = Validator::make($payload, [ + 'source' => ['required', Rule::in(['upload', 's3', 'server'])], + 'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'], + 's3_storage_uuid' => ['required_if:source,s3', 'prohibited_unless:source,s3', 'string'], + 'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'], + 'dump_all' => ['sometimes', 'boolean'], + 'replace_existing' => ['sometimes', 'boolean'], + ]); + $extraFields = array_diff(array_keys($payload), $allowed); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + try { + $source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false)); + $activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId); + } catch (DatabaseImportException $exception) { + return response()->json(['message' => $exception->getMessage()], $exception->status); + } + auditLog('api.database.import_started', [ + 'team_id' => $teamId, + 'database_uuid' => $resource->uuid, + 'database_name' => $resource->name, + 'source' => $source->type, + 'replace_existing' => $source->replaceExisting, + 'activity_id' => $activity->id, + ]); + $url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false); + + return response()->json(['id' => $activity->id, 'status' => data_get($activity, 'properties.status'), 'message' => 'Database import queued.', 'status_url' => $url], 202)->header('Location', $url); + } + + protected function showDatabaseImport(Model $resource, int $teamId, int $activityId): JsonResponse + { + $this->authorize('view', $resource); + $activity = Activity::query()->whereKey($activityId) + ->where('properties->team_id', $teamId) + ->where('properties->type_uuid', $resource->uuid) + ->where('properties->operation', 'database_import')->first(); + if (! $activity) { + return response()->json(['message' => 'Database import not found.'], 404); + } + $status = data_get($activity, 'properties.status'); + $terminal = in_array($status, ['finished', 'error', 'killed', 'cancelled', 'closed'], true); + + return response()->json([ + 'id' => $activity->id, + 'status' => $status, + 'exit_code' => data_get($activity, 'properties.exitCode'), + 'output' => remove_iip(RunRemoteProcess::decodeOutput($activity)), + 'created_at' => $activity->created_at, + 'updated_at' => $activity->updated_at, + 'finished_at' => $terminal ? $activity->updated_at : null, + ]); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7b62b4980a..f6074e0a1e 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -32,8 +32,87 @@ use OpenApi\Attributes as OA; class DatabasesController extends Controller { + use Concerns\HandlesDatabaseImportsApi; use Concerns\HandlesTagsApi; + #[OA\Post( + path: '/databases/{uuid}/imports/uploads', + operationId: 'upload-database-import', + summary: 'Upload database import', + security: [['bearerAuth' => []]], + tags: ['Databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 201, description: 'Upload completed'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function upload_import(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + + return $database ? $this->uploadDatabaseImport($request, $database, $teamId) : response()->json(['message' => 'Database not found.'], 404); + } + + #[OA\Post( + path: '/databases/{uuid}/imports', + operationId: 'create-database-import', + summary: 'Import database backup', + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), + security: [['bearerAuth' => []]], + tags: ['Databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 202, description: 'Import queued'), + new OA\Response(response: 409, description: 'Import already active'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_import(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + + return $database ? $this->startDatabaseImport($request, $database, $teamId, 'api.databases.imports.show', ['uuid' => $uuid]) : response()->json(['message' => 'Database not found.'], 404); + } + + #[OA\Get( + path: '/databases/{uuid}/imports/{activity_id}', + operationId: 'get-database-import', + summary: 'Get database import status', + security: [['bearerAuth' => []]], + tags: ['Databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'activity_id', in: 'path', required: true, description: 'Import activity ID.', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function show_import(Request $request, string $uuid, int $activity_id): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + + return $database ? $this->showDatabaseImport($database, $teamId, $activity_id) : response()->json(['message' => 'Database not found.'], 404); + } + protected function findTaggableResource(string $uuid, int|string $teamId): mixed { return queryDatabaseByUuidWithinTeam($uuid, $teamId); @@ -769,6 +848,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], + 'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365, 'default' => 0], ], ), ) @@ -805,7 +885,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -833,6 +913,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', + 'missing_backup_notification_days' => 'integer|min:0|max:365', ]); if ($validator->fails()) { @@ -1025,6 +1106,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], + 'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365], ], ), ) @@ -1054,7 +1136,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1080,6 +1162,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', + 'missing_backup_notification_days' => 'integer|min:0|max:365', ]); if ($validator->fails()) { return response()->json([ @@ -2429,13 +2512,12 @@ class DatabasesController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', @@ -2586,6 +2668,8 @@ class DatabasesController extends Controller $this->authorize('delete', $database); + $database->delete(); + DeleteResourceJob::dispatch( resource: $database, deleteVolumes: $request->boolean('delete_volumes', true), @@ -4059,7 +4143,6 @@ class DatabasesController extends Controller 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], @@ -4104,14 +4187,13 @@ class DatabasesController extends Controller 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; + $allAllowedFields = ['type', 'name', 'mount_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4147,7 +4229,6 @@ class DatabasesController extends Controller $storage = LocalPersistentVolume::create([ 'name' => $database->uuid.'-'.$request->name, 'mount_path' => $request->mount_path, - 'host_path' => $request->host_path, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), ]); @@ -4299,7 +4380,6 @@ class DatabasesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], additionalProperties: false, @@ -4358,11 +4438,10 @@ class DatabasesController extends Controller 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); - $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'content']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4404,7 +4483,7 @@ class DatabasesController extends Controller } $isReadOnly = $storage->shouldBeReadOnlyInUI(); - $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $editableOnlyFields = ['name', 'mount_path', 'content']; $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); if ($isReadOnly && ! empty($requestedEditableFields)) { @@ -4443,9 +4522,6 @@ class DatabasesController extends Controller if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; } - if ($request->has('host_path')) { - $storage->host_path = $request->host_path; - } } else { if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; @@ -4883,6 +4959,8 @@ class DatabasesController extends Controller 'id', 'created_at', 'updated_at', + 'last_execution_at', + 'missing_backup_notification_sent_at', ])->fill([ 'uuid' => new_public_id(), 'database_id' => $newDatabase->id, diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index f5493d0249..1cca69a663 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -15,6 +15,7 @@ use App\Rules\ValidHostname; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class NotificationsController extends Controller @@ -45,6 +46,7 @@ class NotificationsController extends Controller 'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean', 'status_change_email_notifications' => 'sometimes|boolean', + 'restart_limit_reached_email_notifications' => 'sometimes|boolean', 'backup_success_email_notifications' => 'sometimes|boolean', 'backup_failure_email_notifications' => 'sometimes|boolean', 'scheduled_task_success_email_notifications' => 'sometimes|boolean', @@ -66,6 +68,7 @@ class NotificationsController extends Controller 'deployment_success_discord_notifications' => 'sometimes|boolean', 'deployment_failure_discord_notifications' => 'sometimes|boolean', 'status_change_discord_notifications' => 'sometimes|boolean', + 'restart_limit_reached_discord_notifications' => 'sometimes|boolean', 'backup_success_discord_notifications' => 'sometimes|boolean', 'backup_failure_discord_notifications' => 'sometimes|boolean', 'scheduled_task_success_discord_notifications' => 'sometimes|boolean', @@ -88,6 +91,7 @@ class NotificationsController extends Controller 'deployment_success_slack_notifications' => 'sometimes|boolean', 'deployment_failure_slack_notifications' => 'sometimes|boolean', 'status_change_slack_notifications' => 'sometimes|boolean', + 'restart_limit_reached_slack_notifications' => 'sometimes|boolean', 'backup_success_slack_notifications' => 'sometimes|boolean', 'backup_failure_slack_notifications' => 'sometimes|boolean', 'scheduled_task_success_slack_notifications' => 'sometimes|boolean', @@ -110,6 +114,7 @@ class NotificationsController extends Controller 'deployment_success_telegram_notifications' => 'sometimes|boolean', 'deployment_failure_telegram_notifications' => 'sometimes|boolean', 'status_change_telegram_notifications' => 'sometimes|boolean', + 'restart_limit_reached_telegram_notifications' => 'sometimes|boolean', 'backup_success_telegram_notifications' => 'sometimes|boolean', 'backup_failure_telegram_notifications' => 'sometimes|boolean', 'scheduled_task_success_telegram_notifications' => 'sometimes|boolean', @@ -124,6 +129,7 @@ class NotificationsController extends Controller 'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_restart_limit_reached_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255', @@ -146,6 +152,7 @@ class NotificationsController extends Controller 'deployment_success_pushover_notifications' => 'sometimes|boolean', 'deployment_failure_pushover_notifications' => 'sometimes|boolean', 'status_change_pushover_notifications' => 'sometimes|boolean', + 'restart_limit_reached_pushover_notifications' => 'sometimes|boolean', 'backup_success_pushover_notifications' => 'sometimes|boolean', 'backup_failure_pushover_notifications' => 'sometimes|boolean', 'scheduled_task_success_pushover_notifications' => 'sometimes|boolean', @@ -167,6 +174,7 @@ class NotificationsController extends Controller 'deployment_success_webhook_notifications' => 'sometimes|boolean', 'deployment_failure_webhook_notifications' => 'sometimes|boolean', 'status_change_webhook_notifications' => 'sometimes|boolean', + 'restart_limit_reached_webhook_notifications' => 'sometimes|boolean', 'backup_success_webhook_notifications' => 'sometimes|boolean', 'backup_failure_webhook_notifications' => 'sometimes|boolean', 'scheduled_task_success_webhook_notifications' => 'sometimes|boolean', @@ -249,7 +257,7 @@ class NotificationsController extends Controller $body = $request->json()->all(); $config = $this->channelConfig($channel); - $validator = customApiValidator($body, $config['rules']); + $validator = Validator::make($body, $config['rules']); $extraFields = array_diff(array_keys($body), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 33d21ba5d0..43ce742168 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -12,6 +12,30 @@ use OpenApi\Attributes as OA; securityScheme: 'bearerAuth', description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.')] #[OA\Components( + schemas: [ + new OA\Schema( + schema: 'DatabaseImportRequest', + oneOf: [ + new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]), + new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]), + new OA\Schema(required: ['source', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]), + ], + type: 'object', + ), + new OA\Schema( + schema: 'DatabaseImportStatus', + type: 'object', + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'status', type: 'string', enum: ['queued', 'in_progress', 'finished', 'error', 'killed', 'cancelled', 'closed']), + new OA\Property(property: 'exit_code', type: 'integer', nullable: true), + new OA\Property(property: 'output', type: 'string'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true), + ], + ), + ], responses: [ new OA\Response( response: 400, diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index b3685daa4b..3d3978d1c8 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Events\SentinelSynchronized; use App\Http\Controllers\Controller; use App\Jobs\PushServerUpdateJob; use App\Models\Server; @@ -92,9 +93,16 @@ class SentinelController extends Controller $data = $request->all(); + $wasSentinelLive = $server->sentinel_updated_at !== null && $server->isSentinelLive(); + // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping. + $server->sentinel_waiting_since = null; $server->sentinelHeartbeat(); + if (! $wasSentinelLive) { + SentinelSynchronized::dispatch($server); + } + if ($this->shouldDispatchUpdate($server, $data)) { PushServerUpdateJob::dispatch($server, $data); } @@ -138,7 +146,7 @@ class SentinelController extends Controller /** * Build a stable hash of container state. * - * Covers [name, state] only — metrics, filesystem_usage_root, and + * Covers [name, state, restart_count] only — metrics, filesystem_usage_root, and * health_status are excluded on purpose. Disk % churns constantly, and * health checks can flap between starting/healthy/unhealthy while the * container lifecycle state remains unchanged. Both would otherwise defeat @@ -153,6 +161,7 @@ class SentinelController extends Controller ->map(fn ($c) => [ 'name' => data_get($c, 'name'), 'state' => data_get($c, 'state'), + 'restart_count' => data_get($c, 'restart_count'), ]) ->sortBy('name') ->values() diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index f52bdb4c39..77bc16c1c2 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -12,7 +12,6 @@ use OpenApi\Attributes as OA; class ServerSentinelController extends Controller { private const ALLOWED_FIELDS = [ - 'is_sentinel_enabled', 'is_metrics_enabled', 'is_sentinel_debug_enabled', 'sentinel_token', @@ -20,6 +19,13 @@ class ServerSentinelController extends Controller 'sentinel_metrics_history_days', 'sentinel_push_interval_seconds', 'sentinel_custom_url', + 'traffic_topn', + 'traffic_sample_threshold', + 'traffic_retention_1h_days', + 'traffic_retention_1d_days', + 'is_geoip_enabled', + 'geoip_refresh_days', + 'geoip_maxmind_license_key', ]; private function findServerForTeam(int $teamId, string $uuid): ?Server @@ -36,18 +42,25 @@ class ServerSentinelController extends Controller { $settings = $server->settings; $payload = [ - 'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled, + 'is_sentinel_enabled' => $server->isSentinelEnabled(), 'is_metrics_enabled' => (bool) $settings->is_metrics_enabled, 'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled, 'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds, 'sentinel_metrics_history_days' => (int) $settings->sentinel_metrics_history_days, 'sentinel_push_interval_seconds' => (int) $settings->sentinel_push_interval_seconds, 'sentinel_updated_at' => $server->sentinel_updated_at, + 'traffic_topn' => (int) $settings->traffic_topn, + 'traffic_sample_threshold' => (int) $settings->traffic_sample_threshold, + 'traffic_retention_1h_days' => (int) $settings->traffic_retention_1h_days, + 'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days, + 'is_geoip_enabled' => (bool) $settings->is_geoip_enabled, + 'geoip_refresh_days' => (int) $settings->geoip_refresh_days, ]; if ($this->canReadSensitive()) { $payload['sentinel_token'] = $settings->sentinel_token; $payload['sentinel_custom_url'] = $settings->sentinel_custom_url; + $payload['geoip_maxmind_license_key'] = $settings->geoip_maxmind_license_key; } return $payload; @@ -69,7 +82,7 @@ class ServerSentinelController extends Controller description: 'Sentinel settings.', content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), + new OA\Property(property: 'is_sentinel_enabled', type: 'boolean', readOnly: true, description: 'Sentinel is mandatory on regular managed servers.'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'), @@ -78,6 +91,13 @@ class ServerSentinelController extends Controller new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer'), new OA\Property(property: 'sentinel_custom_url', type: 'string', description: 'Only present with read:sensitive.'), new OA\Property(property: 'sentinel_updated_at', type: 'string', nullable: true), + new OA\Property(property: 'traffic_topn', type: 'integer'), + new OA\Property(property: 'traffic_sample_threshold', type: 'integer'), + new OA\Property(property: 'traffic_retention_1h_days', type: 'integer'), + new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'), + new OA\Property(property: 'is_geoip_enabled', type: 'boolean'), + new OA\Property(property: 'geoip_refresh_days', type: 'integer'), + new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'), ], type: 'object', ), @@ -118,7 +138,6 @@ class ServerSentinelController extends Controller required: true, content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'sentinel_token', type: 'string'), @@ -126,6 +145,13 @@ class ServerSentinelController extends Controller new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer', minimum: 1), new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer', minimum: 10), new OA\Property(property: 'sentinel_custom_url', type: 'string', nullable: true), + new OA\Property(property: 'traffic_topn', type: 'integer', minimum: 1), + new OA\Property(property: 'traffic_sample_threshold', type: 'integer', minimum: 0), + new OA\Property(property: 'traffic_retention_1h_days', type: 'integer', minimum: 1), + new OA\Property(property: 'traffic_retention_1d_days', type: 'integer', minimum: 1), + new OA\Property(property: 'is_geoip_enabled', type: 'boolean'), + new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1), + new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true), ], type: 'object', ), @@ -158,7 +184,6 @@ class ServerSentinelController extends Controller $this->authorize('update', $server); $validator = customApiValidator($request->all(), [ - 'is_sentinel_enabled' => 'boolean', 'is_metrics_enabled' => 'boolean', 'is_sentinel_debug_enabled' => 'boolean', 'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'], @@ -166,6 +191,13 @@ class ServerSentinelController extends Controller 'sentinel_metrics_history_days' => 'integer|min:1', 'sentinel_push_interval_seconds' => 'integer|min:10', 'sentinel_custom_url' => 'nullable|url', + 'traffic_topn' => 'integer|min:1', + 'traffic_sample_threshold' => 'integer|min:0', + 'traffic_retention_1h_days' => 'integer|min:1', + 'traffic_retention_1d_days' => 'integer|min:1', + 'is_geoip_enabled' => 'boolean', + 'geoip_refresh_days' => 'integer|min:1', + 'geoip_maxmind_license_key' => 'nullable|string|max:255', ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); @@ -189,29 +221,12 @@ class ServerSentinelController extends Controller } $settings = $server->settings; - $enablingSentinel = $request->has('is_sentinel_enabled') - && $request->boolean('is_sentinel_enabled') - && ! $settings->is_sentinel_enabled; - - if ($enablingSentinel && $server->isBuildServer()) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']], - ], 422); - } - foreach (self::ALLOWED_FIELDS as $field) { if ($request->has($field)) { $settings->{$field} = $request->input($field); } } - // Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel). - if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) { - $settings->is_metrics_enabled = false; - $settings->is_sentinel_debug_enabled = false; - } - $settings->save(); auditLog('api.server.sentinel.updated', [ diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index f7966c71f1..7f6b94c80a 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -6,6 +6,7 @@ use App\Actions\Server\DeleteServer; use App\Actions\Server\ValidateServer; use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; +use App\Enums\ServerRole; use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Jobs\ValidateAndInstallServerJob; @@ -439,7 +440,7 @@ class ServersController extends Controller 'port' => ['type' => 'integer', 'example' => 22, 'description' => 'The port of the server.'], 'user' => ['type' => 'string', 'example' => 'root', 'description' => 'The user of the server.'], 'private_key_uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the private key.'], - 'is_build_server' => ['type' => 'boolean', 'example' => false, 'description' => 'Is build server.'], + 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'example' => 'both', 'description' => 'Server role.'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'], ], @@ -481,7 +482,7 @@ class ServersController extends Controller )] public function create_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -500,7 +501,7 @@ class ServersController extends Controller 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|required', 'user' => ValidationPatterns::serverUsernameRules(required: false), - 'is_build_server' => 'boolean|nullable', + 'server_role' => 'string|nullable|in:deployment,build,both', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', ], [ @@ -530,8 +531,15 @@ class ServersController extends Controller if (is_null($request->port)) { $request->offsetSet('port', 22); } - if (is_null($request->is_build_server)) { - $request->offsetSet('is_build_server', false); + $serverRole = $request->filled('server_role') + ? ServerRole::from($request->string('server_role')->toString()) + : ServerRole::BOTH; + + if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->exists()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_role' => ['Add another build-capable server before you set this server to deployments only.']], + ], 422); } if (is_null($request->instant_validate)) { $request->offsetSet('instant_validate', false); @@ -569,7 +577,7 @@ class ServersController extends Controller $server->save(); $server->settings()->update([ - 'is_build_server' => $request->is_build_server, + 'server_role' => $serverRole, ]); if ($request->instant_validate) { ValidateServer::dispatch($server); @@ -580,7 +588,7 @@ class ServersController extends Controller 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'ip' => $server->ip, - 'is_build_server' => (bool) $request->is_build_server, + 'server_role' => $serverRole->value, ]); return response()->json([ @@ -614,7 +622,7 @@ class ServersController extends Controller 'port' => ['type' => 'integer', 'description' => 'The port of the server.'], 'user' => ['type' => 'string', 'description' => 'The user of the server.'], 'private_key_uuid' => ['type' => 'string', 'description' => 'The UUID of the private key.'], - 'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'], + 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'], 'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'], @@ -659,7 +667,7 @@ class ServersController extends Controller )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -677,7 +685,7 @@ class ServersController extends Controller 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|nullable', 'user' => ValidationPatterns::serverUsernameRules(required: false), - 'is_build_server' => 'boolean|nullable', + 'server_role' => 'string|nullable|in:deployment,build,both', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', 'concurrent_builds' => 'integer|min:1', @@ -734,17 +742,29 @@ class ServersController extends Controller ], 422); } - if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) { + $serverRole = null; + if ($request->filled('server_role')) { + $serverRole = ServerRole::from($request->string('server_role')->toString()); + } + + if ($serverRole === ServerRole::BUILD && ! $server->isBuildServer() && ! $server->isEmpty()) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']], + 'errors' => ['server_role' => ['A server with existing resources cannot be configured as build only.']], + ], 422); + } + + if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->whereKeyNot($server->id)->exists()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_role' => ['Add another build-capable server before you set this server to deployments only.']], ], 422); } $server->update($updateFields); - if ($request->has('is_build_server')) { + if ($serverRole !== null) { $server->settings()->update([ - 'is_build_server' => $request->boolean('is_build_server'), + 'server_role' => $serverRole, ]); } diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index e8446467de..dda70c27eb 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -9,6 +9,7 @@ use App\Actions\Service\UpdateServiceApplicationFromApi; use App\Http\Controllers\Controller; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -257,6 +258,7 @@ class ServiceApplicationsController extends Controller 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), 'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true), + 'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'), ] ) ) @@ -330,10 +332,11 @@ class ServiceApplicationsController extends Controller 'is_gzip_enabled', 'is_stripprefix_enabled', 'is_force_https_enabled', + 'max_restart_count', ]; $validationRules = [ - 'url' => 'nullable|string', + 'url' => ValidationPatterns::applicationDomainRules(), 'noindex_domains' => 'sometimes|array|nullable', 'noindex_domains.*' => 'string', 'human_name' => 'nullable|string|max:255', @@ -344,6 +347,7 @@ class ServiceApplicationsController extends Controller 'is_gzip_enabled' => 'sometimes|boolean', 'is_stripprefix_enabled' => 'sometimes|boolean', 'is_force_https_enabled' => 'sometimes|boolean', + 'max_restart_count' => 'sometimes|integer|min:0', ]; $validator = Validator::make($payload, $validationRules); @@ -398,9 +402,12 @@ class ServiceApplicationsController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), ], responses: [ @@ -447,7 +454,16 @@ class ServiceApplicationsController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), - new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', + required: false, + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]), + ), ], responses: [ new OA\Response( diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 480ff4e557..1d2a602461 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -18,6 +18,84 @@ use OpenApi\Attributes as OA; class ServiceDatabasesController extends Controller { + use Concerns\HandlesDatabaseImportsApi; + + #[OA\Post( + path: '/services/{uuid}/databases/{database_uuid}/imports/uploads', + operationId: 'upload-service-database-import', + summary: 'Upload service database import', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 201, description: 'Upload completed'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function upload_import(Request $request): JsonResponse + { + return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->uploadDatabaseImport($request, $database, $teamId)); + } + + #[OA\Post( + path: '/services/{uuid}/databases/{database_uuid}/imports', + operationId: 'create-service-database-import', + summary: 'Import service database backup', + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 202, description: 'Import queued'), + new OA\Response(response: 409, description: 'Import already active'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_import(Request $request): JsonResponse + { + return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->startDatabaseImport($request, $database, $teamId, 'api.service-databases.imports.show', ['uuid' => $request->route('uuid'), 'database_uuid' => $database->uuid])); + } + + #[OA\Get( + path: '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', + operationId: 'get-service-database-import', + summary: 'Get service database import status', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'activity_id', in: 'path', description: 'Import activity ID.', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function show_import(Request $request): JsonResponse + { + return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->showDatabaseImport($database, $teamId, (int) $request->route('activity_id'))); + } + + private function withImportDatabase(Request $request, callable $callback): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + $database = $service ? $this->resolveServiceDatabase($request, $service) : null; + + return $database ? $callback($database, $teamId) : response()->json(['message' => 'Service database not found.'], 404); + } + private function removeSensitiveData(ServiceDatabase $serviceDatabase): array { $serviceDatabase->makeHidden([ @@ -288,7 +366,16 @@ class ServiceDatabasesController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), - new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', + required: false, + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]), + ), ], responses: [ new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])), diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index d7d4953f9c..099c126624 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -386,7 +386,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -602,7 +602,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -869,13 +869,12 @@ class ServicesController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', @@ -1020,6 +1019,8 @@ class ServicesController extends Controller $this->authorize('delete', $service); + $service->delete(); + DeleteResourceJob::dispatch( resource: $service, deleteVolumes: $request->boolean('delete_volumes', true), @@ -1185,7 +1186,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', ]; @@ -2420,7 +2421,6 @@ class ServicesController extends Controller 'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], @@ -2466,14 +2466,13 @@ class ServicesController extends Controller 'resource_uuid' => 'required|string', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; + $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -2517,7 +2516,6 @@ class ServicesController extends Controller $storage = LocalPersistentVolume::create([ 'name' => $subResource->uuid.'-'.$request->name, 'mount_path' => $request->mount_path, - 'host_path' => $request->host_path, 'resource_id' => $subResource->id, 'resource_type' => $subResource->getMorphClass(), ]); @@ -2669,7 +2667,6 @@ class ServicesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], additionalProperties: false, @@ -2731,11 +2728,10 @@ class ServicesController extends Controller 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); - $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'content']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -2804,7 +2800,7 @@ class ServicesController extends Controller } $isReadOnly = $storage->shouldBeReadOnlyInUI(); - $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $editableOnlyFields = ['name', 'mount_path', 'content']; $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); if ($isReadOnly && ! empty($requestedEditableFields)) { @@ -2843,9 +2839,6 @@ class ServicesController extends Controller if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; } - if ($request->has('host_path')) { - $storage->host_path = $request->host_path; - } } else { if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index b9f8572673..ed8d34d226 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -224,6 +225,58 @@ class TeamController extends Controller ); } + #[OA\Patch( + summary: 'Update authenticated team', + description: 'Update settings for the team bound to the API token.', + path: '/team', + operationId: 'update-token-team', + security: [['bearerAuth' => []]], + tags: ['Teams'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['is_build_server_fallback_enabled'], + properties: [ + 'is_build_server_fallback_enabled' => [ + 'type' => 'boolean', + 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.', + ], + ], + ), + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Updated team.', content: new OA\JsonContent(ref: '#/components/schemas/Team')), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_current_team(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); + if (is_null($team)) { + return response()->json(['message' => 'Team not found.'], 404); + } + + $this->authorize('update', $team); + $validated = $request->validate([ + 'is_build_server_fallback_enabled' => ['required', 'boolean'], + ]); + + $team->update($validated); + + return response()->json($this->removeSensitiveData($team)); + } + #[OA\Get( summary: 'Authenticated Team Members', description: 'Get members of the team bound to the API token.', diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 93d27615a7..c837cc49c8 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -22,7 +22,11 @@ class OauthController extends Controller try { $oauthSetting = $this->enabledProvider($provider); $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); - $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); + $user = $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); + + if ($oauthLoginService->requiresTwoFactorChallenge($user)) { + return redirect()->route('two-factor.login'); + } return redirect('/'); } catch (\Exception $e) { @@ -36,6 +40,11 @@ class OauthController extends Controller private function logCallbackFailure(string $provider, \Throwable $exception): void { + auditLog('auth.oauth.callback_failed', [ + 'provider' => $provider, + 'exception_class' => $exception::class, + 'reason' => $exception instanceof HttpException ? 'access_denied' : 'callback_error', + ], 'warning'); Log::error('OAuth callback failed.', [ 'provider' => $provider, 'exception_class' => $exception::class, diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index 2cf01400e8..f53cef7c51 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -14,7 +14,7 @@ class ProfileAvatarController extends Controller return response($contents, 200, [ 'Content-Type' => 'image/jpeg', - 'Cache-Control' => 'private, max-age=300', + 'Cache-Control' => 'private, max-age=31536000, immutable', ]); } } diff --git a/app/Http/Controllers/ProjectIconController.php b/app/Http/Controllers/ProjectIconController.php index fb7ebc8860..d99e9166d0 100644 --- a/app/Http/Controllers/ProjectIconController.php +++ b/app/Http/Controllers/ProjectIconController.php @@ -15,6 +15,9 @@ class ProjectIconController extends Controller abort_if($contents === null, 404); - return response($contents)->header('Content-Type', 'image/jpeg'); + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=31536000, immutable', + ]); } } diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index fea55586bc..03b253acc0 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -6,6 +6,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\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ class Bitbucket extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ValidatesPreviewDeploymentRepository; public function manual(Request $request) { @@ -90,7 +92,7 @@ class Bitbucket extends Controller continue; } - $payload = $request->getContent(); + $rawPayload = $request->getContent(); $parts = explode('=', $x_bitbucket_token, 2); if (count($parts) !== 2 || $parts[0] !== 'sha256') { @@ -105,7 +107,7 @@ class Bitbucket extends Controller continue; } $hash = $parts[1]; - $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); + $payloadHash = hash_hmac('sha256', $rawPayload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { auditLogWebhookFailure('bitbucket', 'invalid_signature', [ 'application_uuid' => $application->uuid, @@ -182,6 +184,15 @@ class Bitbucket extends Controller } if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') { if ($application->isPRDeployable()) { + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'pullrequest.source.repository.uuid'), + data_get($payload, 'pullrequest.destination.repository.uuid'), + data_get($payload, 'repository.uuid'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr ?? false) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index 0463790eb7..65c92f1349 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\Webhook\Concerns; use App\Models\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; -use Illuminate\Support\Str; trait MatchesManualWebhookApplications { @@ -79,12 +78,8 @@ trait MatchesManualWebhookApplications if (is_array($parts) && isset($parts['scheme'])) { $path = data_get($parts, 'path'); - } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { - $path = Str::after($gitRepository, ':'); - // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo". - // Strip the leading numeric port segment so the path matches the webhook - // payload's owner/repo, consistent with convertGitUrl() in shared.php. - $path = preg_replace('#^\d+/#', '', $path) ?? $path; + } elseif (($scp = parseScpStyleGitUrl($gitRepository)) !== null) { + $path = $scp['path']; } else { $path = $gitRepository; } diff --git a/app/Http/Controllers/Webhook/Concerns/ValidatesPreviewDeploymentRepository.php b/app/Http/Controllers/Webhook/Concerns/ValidatesPreviewDeploymentRepository.php new file mode 100644 index 0000000000..0c5692049b --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/ValidatesPreviewDeploymentRepository.php @@ -0,0 +1,29 @@ +contains(fn (mixed $identity): bool => ! is_scalar($identity) || trim((string) $identity) === '')) { + return false; + } + + $sourceRepository = trim((string) $sourceRepository); + $targetRepository = trim((string) $targetRepository); + $webhookRepository = trim((string) $webhookRepository); + + if (! hash_equals($targetRepository, $webhookRepository)) { + return false; + } + + return hash_equals($sourceRepository, $targetRepository) || $publicPreviewsEnabled; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index a59cb54982..9c40107217 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -6,6 +6,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\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -16,6 +17,7 @@ class Gitea extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ValidatesPreviewDeploymentRepository; public function manual(Request $request) { @@ -184,6 +186,15 @@ class Gitea extends Controller if ($x_gitea_event === 'pull_request') { if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') { if ($application->isPRDeployable()) { + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'pull_request.head.repo.id'), + data_get($payload, 'pull_request.base.repo.id'), + data_get($payload, 'repository.id'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr ?? false) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 28e92dcd49..c4fdc5fd5c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -83,7 +83,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -334,7 +337,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $applications->get(); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index c9a554e2a5..eb24b460f7 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -6,6 +6,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\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Livewire\Source\Gitlab\Change as GitlabSource; use App\Models\Application; use App\Models\ApplicationPreview; @@ -21,6 +22,7 @@ class Gitlab extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ValidatesPreviewDeploymentRepository; public function redirect(Request $request) { @@ -245,6 +247,15 @@ class Gitlab extends Controller continue; } + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'object_attributes.source_project_id'), + data_get($payload, 'object_attributes.target_project_id'), + data_get($payload, 'project.id'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr) { $return_payloads->push([ 'application' => $application->name, @@ -532,6 +543,15 @@ class Gitlab extends Controller if ($x_gitlab_event === 'merge_request') { if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') { if ($application->isPRDeployable()) { + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'object_attributes.source_project_id'), + data_get($payload, 'object_attributes.target_project_id'), + data_get($payload, 'project.id'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr ?? false) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index 41e70b2ce0..1b5dd31385 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -4,17 +4,24 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; use App\Jobs\StripeProcessJob; -use Exception; use Illuminate\Http\Request; use Stripe\Exception\SignatureVerificationException; use Stripe\Webhook; +use Throwable; class Stripe extends Controller { public function events(Request $request) { try { + $apiKey = config('subscription.stripe_api_key'); $webhookSecret = config('subscription.stripe_webhook_secret'); + if (! is_string($apiKey) || trim($apiKey) === '' || ! is_string($webhookSecret) || trim($webhookSecret) === '') { + auditLogWebhookFailure('stripe', 'stripe_not_configured'); + + return response('Invalid signature.', 400); + } + $signature = $request->header('Stripe-Signature'); $event = Webhook::constructEvent( $request->getContent(), @@ -24,14 +31,14 @@ class Stripe extends Controller StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); - } catch (SignatureVerificationException $e) { - auditLogWebhookFailure('stripe', 'invalid_signature', [ - 'error' => $e->getMessage(), - ]); + } catch (SignatureVerificationException) { + auditLogWebhookFailure('stripe', 'invalid_signature'); - return response($e->getMessage(), 400); - } catch (Exception $e) { - return response($e->getMessage(), 400); + return response('Invalid signature.', 400); + } catch (Throwable) { + auditLogWebhookFailure('stripe', 'invalid_payload'); + + return response('Invalid webhook.', 400); } } } diff --git a/app/Http/Middleware/DecideWhatToDoWithUser.php b/app/Http/Middleware/DecideWhatToDoWithUser.php index dbf261f4db..6babdb69a0 100644 --- a/app/Http/Middleware/DecideWhatToDoWithUser.php +++ b/app/Http/Middleware/DecideWhatToDoWithUser.php @@ -18,9 +18,24 @@ class DecideWhatToDoWithUser } if (auth()?->user()?->currentTeam()) { refreshSession(auth()->user()->currentTeam()); + // A team is already active; the selection screen no longer applies. + if ($request->routeIs('team.select')) { + return redirect()->route('dashboard'); + } } elseif (auth()?->user()?->teams?->count() > 0) { - // User's session team is invalid (e.g., removed from team), switch to first available team - refreshSession(auth()->user()->teams->first()); + // No active team in the session (fresh login or invalidated selection). + // Restore the last active team, or the sole team of a single-team user. + $resolvedTeam = auth()->user()->resolveStoredTeam(); + if ($resolvedTeam) { + refreshSession($resolvedTeam); + } elseif ($request->routeIs('team.select') || $request->routeIs('*livewire.update')) { + // Ambiguous choice: let the user pick a team on the selection screen. + // Livewire's update endpoint must pass through too, otherwise the + // selection action's AJAX call is redirected to HTML and never runs. + return $next($request); + } else { + return redirect()->route('team.select'); + } } if (! auth()->user() || ! isCloud()) { if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a70d917815..9987bc960b 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -45,6 +45,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env'; + public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env'; + + public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env'; + private const BUILD_SCRIPT_PATH = '/artifacts/build.sh'; private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; @@ -205,6 +209,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerSecretsSupported = false; + private bool $dockerSecretsAvailable = false; + + private bool $useBuildtimeEnvironmentLauncher = false; + private bool $skip_build = false; private Collection|string $build_secrets; @@ -229,7 +237,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->deployment_uuid = $this->application_deployment_queue->deployment_uuid; $this->pull_request_id = $this->application_deployment_queue->pull_request_id; - $this->commit = $this->application_deployment_queue->commit; + $this->commit = validateGitRef($this->application_deployment_queue->commit, 'deployment commit'); $this->rollback = $this->application_deployment_queue->rollback; $this->disableBuildCache = $this->application->settings->disable_build_cache; $this->force_rebuild = $this->application_deployment_queue->force_rebuild; @@ -266,13 +274,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id); - if ($this->application->settings->custom_internal_name && ! $this->application->settings->is_consistent_container_name_enabled) { - if ($this->pull_request_id === 0) { - $this->container_name = $this->application->settings->custom_internal_name; - } else { - $this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); - } - } $this->saved_outputs = collect(); @@ -310,6 +311,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } + try { + $this->validateDeploymentEnvironmentVariableKeys(); + } catch (Exception $e) { + $this->fail($e); + throw $e; + } + $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'horizon_job_worker' => gethostname(), @@ -338,7 +346,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($containerName === 'coolify-proxy') { continue; } - if (preg_match('/-(\d{12})/', $containerName)) { + if (isGeneratedContainerName($containerName)) { continue; } $containerIp = data_get($container, 'IPv4Address'); @@ -364,21 +372,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Check custom port ['repository' => $this->customRepository, 'port' => $this->customPort] = $this->application->customRepository(); - if (data_get($this->application, 'settings.is_build_server_enabled')) { - $teamId = data_get($this->application, 'environment.project.team.id'); - $buildServers = Server::buildServers($teamId)->get(); - if ($buildServers->count() === 0) { - $this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.'); - $this->build_server = $this->server; - } else { - $this->build_server = $buildServers->random(); - $this->application_deployment_queue->build_server_id = $this->build_server->id; - $this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name})."); - $this->use_build_server = true; - } - } else { - $this->build_server = $this->server; - } + $this->selectBuildServer(); $this->detectBuildKitCapabilities(); $this->decide_what_to_do(); } catch (Exception $e) { @@ -427,8 +421,47 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + private function selectBuildServer(): void + { + if (! data_get($this->application, 'settings.is_build_server_enabled')) { + $this->build_server = $this->server; + + return; + } + + $team = $this->application->environment->project->team; + $buildServers = Server::buildServers($team->id)->get(); + + if ($buildServers->isEmpty()) { + if (! $team->is_build_server_fallback_enabled) { + throw new DeploymentException('No available dedicated build server was found. Enable a usable build server for this team or allow fallback to the deployment server in the team settings.'); + } + + $this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.'); + $this->build_server = $this->server; + + return; + } + + $this->build_server = $buildServers->random(); + if ($this->build_server->is($this->server)) { + $this->application_deployment_queue->addLogEntry("Using deployment server ({$this->server->name}) for the build."); + + return; + } + + $this->application_deployment_queue->build_server_id = $this->build_server->id; + $this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name})."); + $this->use_build_server = true; + } + private function detectBuildKitCapabilities(): void { + $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; + $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; @@ -479,18 +512,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - // If build secrets are enabled and BuildKit is available, verify --secret flag support - if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported) { $secretsTest = instant_remote_process( ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($secretsTest) === 'supported') { - $this->dockerSecretsSupported = true; - $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); - } else { - $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = true; + if ($this->application->settings->use_build_secrets) { + $this->dockerSecretsSupported = true; + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } + } elseif ($this->application->settings->use_build_secrets) { $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } @@ -498,6 +532,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } } @@ -667,7 +702,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue [executeInDocker($this->deployment_uuid, "stat -c '%F' {$realPathInGit}"), 'hidden' => true, 'ignore_errors' => true, 'save' => $saveName] ); if ($this->saved_outputs->has($saveName)) { - $fileStat = $this->saved_outputs->get($saveName); + $fileStat = $this->trimmedSavedOutput($saveName); if ($fileStat->value() === 'directory' && ! $fileStorage->is_directory) { $fileStorage->is_directory = true; $fileStorage->content = null; @@ -716,6 +751,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } + $this->validateComposeBuildPaths($composeFile); + // Add build secrets to compose file if enabled and BuildKit is supported if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $composeFile = $this->add_build_secrets_to_compose($composeFile); @@ -804,6 +841,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); + $this->pull_docker_compose_images(); + $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -907,6 +946,26 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('New container started.'); } + private function pull_docker_compose_images(): void + { + $this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.'); + + if ($this->use_build_server) { + $this->write_deployment_configurations(); + $this->server = $this->mainServer; + $workdir = $this->application->workdir(); + $command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable"; + } else { + $workdir = $this->workdir; + $command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable"); + } + + $this->execute_remote_command([ + $command, + 'hidden' => true, + ]); + } + private function deploy_dockerfile_buildpack() { $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); @@ -1757,11 +1816,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($planVariables as $key => $value) { + $key = (string) $key; + // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) { continue; } + $key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan'); $escapedValue = escapeBashEnvValue($value); $envs_dict[$key] = $escapedValue; @@ -1961,6 +2023,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Convert dictionary back to collection in KEY=VALUE format $envs = collect([]); foreach ($envs_dict as $key => $value) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment'); $envs->push($key.'='.$value); } @@ -1974,45 +2037,144 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $envs; } - private function save_buildtime_environment_variables() + private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string { - // Generate build-time environment variables locally - $environment_variables = $this->generate_buildtime_environment_variables(); - // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images - if ($environment_variables->isNotEmpty()) { - $envs_base64 = base64_encode($environment_variables->implode("\n")); - - $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); - - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), - 'skip_command_log' => true, - ] - ); - - if (isDev()) { - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), - 'hidden' => true, - ] - ); + try { + if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) { + throw new \InvalidArgumentException('Invalid build-time environment variable key.'); } - } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { - // For build packs that source the build-time .env file, create an empty file even if there are no build-time variables - // This ensures the file exists when referenced in build commands - $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), - ] + return $key; + } catch (\InvalidArgumentException $exception) { + $this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin); + + throw new DeploymentException( + "Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', + previous: $exception, ); } } + private function validateDeploymentEnvironmentVariableKeys(): void + { + $environmentVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->get(['key']) + : $this->application->environment_variables_preview()->get(['key']); + + foreach ($environmentVariables as $environmentVariable) { + $this->validatedBuildtimeEnvironmentVariableKey((string) $environmentVariable->key, 'the deployment environment'); + } + } + + private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void + { + $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + $this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr'); + $this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr'); + $this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info'); + + if ($origin === 'the Nixpacks plan') { + $this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + $this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info'); + $this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info'); + } else { + $this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + } + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + } + + private function logSuggestedShellEnvironmentVariableKey(string $key): void + { + $suggestedKey = str_replace('.', '_', $key); + if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) { + return; + } + + $displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey); + + $this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info'); + } + + private function save_buildtime_environment_variables() + { + $environment_variables = $this->generate_buildtime_environment_variables(); + [$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool { + [$key] = explode('=', $environmentVariable, 2); + + return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1; + }); + + if ($dotted_environment_variables->isEmpty()) { + $this->useBuildtimeEnvironmentLauncher = false; + + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), + 'skip_command_log' => true, + ]); + + if (isDev()) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), + 'hidden' => true, + ]); + } + } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { + $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), + ]); + } + + return; + } + + $this->useBuildtimeEnvironmentLauncher = true; + + $launcher = [ + '#!/bin/bash', + 'set -a', + 'source '.self::BUILD_TIME_SHELL_ENV_PATH, + 'set +a', + ]; + + $launcher[] = 'exec env \\'; + foreach ($dotted_environment_variables as $environmentVariable) { + $launcher[] = " {$environmentVariable} \\"; + } + $launcher[] = ' "$@"'; + + $files = [ + self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"), + self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"), + self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n", + ]; + + $this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true); + + foreach ($files as $path => $contents) { + $contents_base64 = base64_encode($contents); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"), + 'skip_command_log' => true, + ]); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH), + ]); + } + private function elixir_finetunes() { if ($this->pull_request_id === 0) { @@ -2084,7 +2246,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->write_deployment_configurations(); $this->server = $this->mainServer; } - if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) { + if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) { $this->application_deployment_queue->addLogEntry('----------------------------------------'); if (count($this->application->ports_mappings_array) > 0) { $this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.'); @@ -2092,7 +2254,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ((bool) $this->application->settings->is_consistent_container_name_enabled) { $this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.'); } - if (str($this->application->settings->custom_internal_name)->isNotEmpty()) { + if ((bool) $this->application->settings->is_consistent_container_name_enabled && str($this->application->settings->custom_internal_name)->isNotEmpty()) { $this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.'); } if ($this->pull_request_id !== 0) { @@ -2133,6 +2295,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.'); } if ($this->container_name) { + $escapedContainerName = escapeshellarg($this->container_name); $counter = 1; $this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.'); if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) { @@ -2148,13 +2311,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue while ($counter <= $this->application->health_check_retries) { $this->execute_remote_command( [ - "docker inspect --format='{{json .State.Health.Status}}' {$this->container_name}", + "docker inspect --format='{{json .State.Health.Status}}' {$escapedContainerName}", 'hidden' => true, 'save' => 'health_check', 'append' => false, ], [ - "docker inspect --format='{{json .State.Health.Log}}' {$this->container_name}", + "docker inspect --format='{{json .State.Health.Log}}' {$escapedContainerName}", 'hidden' => true, 'save' => 'health_check_logs', 'append' => false, @@ -2170,12 +2333,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}"); } - if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'healthy') { + $healthCheckStatus = $this->trimmedSavedOutput('health_check')->replace('"', '')->value(); + if ($healthCheckStatus === 'healthy') { $this->newVersionIsHealthy = true; $this->application->update(['status' => 'running']); $this->application_deployment_queue->addLogEntry('New container is healthy.'); break; - } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { + } elseif ($healthCheckStatus === 'unhealthy') { $this->newVersionIsHealthy = false; $this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error'); $this->query_logs(); @@ -2188,7 +2352,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $sleeptime++; } } - if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'starting') { + if ($this->trimmedSavedOutput('health_check')->replace('"', '')->value() === 'starting') { $this->query_logs(); } } @@ -2200,11 +2364,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function query_logs() { + $escapedContainerName = escapeshellarg($this->container_name); $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry('Container logs:'); $this->execute_remote_command( [ - 'command' => "docker logs -n 100 {$this->container_name}", + 'command' => "docker logs -n 100 {$escapedContainerName}", 'type' => 'stderr', 'ignore_errors' => true, ], @@ -2383,12 +2548,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue destination: $destination, no_questions_asked: true, ); - $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [ - 'project_uuid' => data_get($this->application, 'environment.project.uuid'), - 'application_uuid' => data_get($this->application, 'uuid'), - 'deployment_uuid' => $deployment_uuid, - 'environment_uuid' => data_get($this->application, 'environment.uuid'), - ])); + $deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}"; + $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}"); } } @@ -2406,15 +2567,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $fqdn = $this->preview->fqdn; } if (isset($fqdn)) { - $url = Url::fromString($fqdn); - $fqdn = $url->getHost(); - $url = $url->withHost($fqdn)->withPort(null)->__toString(); - if ((int) $this->application->compose_parsing_version >= 3) { - $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' '; - $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' '; - } else { - $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' '; - $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' '; + $domains = str($fqdn)->explode(',') + ->map(fn (string $domain) => trim($domain)) + ->filter() + ->filter(fn (string $domain) => isValidDomainUrl($domain)); + if ($domains->isNotEmpty()) { + $url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(','); + $fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(','); + if ((int) $this->application->compose_parsing_version >= 3) { + $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' '; + $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' '; + } else { + $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' '; + $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' '; + } } } if (isset($this->application->git_branch)) { @@ -2569,7 +2735,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ] ); if ($this->saved_outputs->get('commit_message')) { - $commit_message = str($this->saved_outputs->get('commit_message')); + $commit_message = $this->trimmedSavedOutput('commit_message'); $this->application_deployment_queue->commit_message = $commit_message->value(); ApplicationDeploymentQueue::whereCommit($this->commit)->whereApplicationId($this->application->id)->update( ['commit_message' => $commit_message->value()] @@ -2639,7 +2805,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], ); if ($this->saved_outputs->get('nixpacks_type')) { - $this->nixpacks_type = $this->saved_outputs->get('nixpacks_type'); + $this->nixpacks_type = $this->trimmedSavedOutput('nixpacks_type')->value(); if (str($this->nixpacks_type)->isEmpty()) { throw new DeploymentException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers'); } @@ -2878,6 +3044,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return 'env '.$variables ->map(function ($value, $key) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Railpack environment'); + return escapeShellValue("{$key}={$value}"); }) ->implode(' ').' '; @@ -2891,6 +3059,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return ' '.$variables ->map(function ($value, $key) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Railpack environment'); + return '--secret '.escapeShellValue("id={$key},env={$key}"); }) ->implode(' '); @@ -3443,6 +3613,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Always use .env file $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; + if ($this->application->settings->stop_grace_period !== null) { + $docker_compose['services'][$this->container_name]['stop_grace_period'] = $this->application->settings->stopGracePeriodSeconds().'s'; + } + // Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If healthcheck is disabled, no healthcheck will be added @@ -3565,24 +3739,22 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($this->pull_request_id === 0) { $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); if ((bool) $this->application->settings->is_consistent_container_name_enabled) { - if (! $this->application->settings->custom_internal_name) { - $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; - if (count($custom_compose) > 0) { - $ipv4 = data_get($custom_compose, 'ip.0'); - $ipv6 = data_get($custom_compose, 'ip6.0'); - data_forget($custom_compose, 'ip'); - data_forget($custom_compose, 'ip6'); - if ($ipv4 || $ipv6) { - data_forget($docker_compose['services'][$this->application->uuid], 'networks'); - } - if ($ipv4) { - $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; - } - if ($ipv6) { - $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; - } - $docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose); + $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; + if ($this->container_name !== $this->application->uuid) { + unset($docker_compose['services'][$this->container_name]); + } + if (count($custom_compose) > 0) { + $ipv4 = data_get($custom_compose, 'ip.0'); + $ipv6 = data_get($custom_compose, 'ip6.0'); + data_forget($custom_compose, 'ip'); + data_forget($custom_compose, 'ip6'); + if ($ipv4) { + $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; } + if ($ipv6) { + $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; + } + $docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose); } } else { if (count($custom_compose) > 0) { @@ -3787,7 +3959,13 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); */ private function wrap_build_command_with_env_export(string $build_command): string { - return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + if (! $this->useBuildtimeEnvironmentLauncher) { + return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + } + + $escapedBuildCommand = escapeBashEnvValue($build_command); + + return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}"; } private function build_image() @@ -3797,7 +3975,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Traditional build args approach - generate COOLIFY_ variables locally $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { - $this->build_args->push("--build-arg '{$key}'"); + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Coolify build environment'); + $this->build_args->push('--build-arg '.escapeshellarg($key)); }); } @@ -4121,11 +4300,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($skipRemove) { $this->execute_remote_command( - [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true] + [dockerStopCommand($timeout, escapeshellarg($containerName), $this->server), 'hidden' => true, 'ignore_errors' => true] ); } else { $this->execute_remote_command( - [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true] + [dockerStopCommand($timeout, escapeshellarg($containerName), $this->server), 'hidden' => true, 'ignore_errors' => true] ); $this->removeContainerWithTimeout($containerName); } @@ -4169,8 +4348,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); try { $this->application_deployment_queue->addLogEntry('Removing old containers.'); if ($this->newVersionIsHealthy || $force) { - if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { - $this->graceful_shutdown_container($this->container_name); + if ($this->application->settings->is_consistent_container_name_enabled) { + $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); + $this->containerNamesToRemove($containers)->each(function (string $containerName) { + $this->graceful_shutdown_container($containerName); + }); } else { $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { @@ -4209,6 +4391,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + private function containerNamesToRemove(Collection $containers): Collection + { + return $containers + ->pluck('Names') + ->push($this->container_name) + ->filter() + ->unique() + ->values(); + } + private function start_by_compose_file() { try { @@ -4297,6 +4489,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->analyzeBuildTimeVariables($variables); } + $requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks' + && $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.')); + + if ($requiresDottedEnvironmentSecrets) { + if (! $this->dockerSecretsAvailable) { + $dottedKeys = $variables->keys() + ->filter(fn ($key): bool => str_contains((string) $key, '.')) + ->implode(', '); + + throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server."); + } + + $this->dockerSecretsSupported = true; + } + if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; @@ -4324,7 +4531,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->build_args = generateDockerBuildArgs($vars_with_metadata); if ($secrets_hash) { - $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + $this->build_args->push('--build-arg '.escapeshellarg("COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}")); } } } @@ -4361,6 +4568,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Map to simple array format for the helper function $vars_array = $variables->map(function ($value, $key) use ($env_vars) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'); $env = $env_vars->firstWhere('key', $key); return [ @@ -4371,7 +4579,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); }); $env_flags = generateDockerEnvFlags($vars_array); - $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"; + $env_flags .= ' -e '.escapeshellarg("COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); return $env_flags; } @@ -4386,7 +4594,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->build_secrets = $variables ->map(function ($value, $key) { - return "--secret id={$key},env={$key}"; + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'); + + return '--secret '.escapeshellarg("id={$key},env={$key}"); }) ->implode(' '); @@ -4477,11 +4687,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $argsToInsert->push("ARG {$env->key}"); - } else { - $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env))); - } + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile'); + $argsToInsert->push("ARG {$key}"); } // Add Coolify variables as ARGs if ($this->coolify_variables) { @@ -4499,11 +4706,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $argsToInsert->push("ARG {$env->key}"); - } else { - $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env))); - } + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile'); + $argsToInsert->push("ARG {$key}"); } // Add Coolify variables as ARGs if ($this->coolify_variables) { @@ -4560,7 +4764,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function modify_dockerfile_for_secrets($dockerfile_path) { // Only process if build secrets are enabled and we have secrets to mount - if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { + if (empty($this->build_secrets)) { return; } @@ -4584,18 +4788,55 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->generate_env_variables(); } - $variables = $this->env_args; + $variables = $this->application->build_pack === 'nixpacks' + ? collect($this->nixpacks_plan_json->get('variables')) + : $this->env_args; if ($variables->isEmpty()) { return; } + $dottedKeys = $variables->keys() + ->map(fn ($key): string => (string) $key) + ->filter(fn (string $key): bool => str_contains($key, '.')); + + if ($dottedKeys->isNotEmpty()) { + $originalDockerfile = $dockerfile; + $dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string { + $trimmedLine = trim($line); + + if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) { + return $line; + } + + [$instruction, $arguments] = explode(' ', $trimmedLine, 2); + $filteredArguments = collect(preg_split('/\s+/', $arguments)) + ->reject(function (string $argument) use ($dottedKeys): bool { + $key = str($argument)->before('=')->toString(); + + return $dottedKeys->contains($key); + }); + + if ($filteredArguments->isEmpty()) { + return null; + } + + return $instruction.' '.$filteredArguments->implode(' '); + })->filter()->values(); + + $modified = $dockerfile->all() !== $originalDockerfile->values()->all(); + } + // Generate mount strings for all secrets - $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); + $mountStrings = $variables->map(function ($value, $key) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile'); + + return "--mount=type=secret,id={$key},env={$key}"; + })->implode(' '); // Add mount for the secrets hash to ensure cache invalidation $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; - $modified = false; + $modified ??= false; $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { $trimmed = ltrim($line); @@ -4655,38 +4896,25 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); continue; } - $context = '.'; - $dockerfile = 'Dockerfile'; - - if (is_string($service['build'])) { - $context = $service['build']; - } elseif (is_array($service['build'])) { - $context = data_get($service['build'], 'context', '.'); - $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile'); - } - - $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/'); - if (str_starts_with($dockerfilePath, './')) { - $dockerfilePath = substr($dockerfilePath, 2); - } - if (str_starts_with($dockerfilePath, '/')) { - $dockerfilePath = substr($dockerfilePath, 1); - } + $dockerfilePath = $this->resolveComposeDockerfilePath($service['build']); + $fullDockerfilePath = escapeshellarg("{$this->workdir}/{$dockerfilePath}"); $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"), + executeInDocker($this->deployment_uuid, "resolved_path=$(realpath -e -- {$fullDockerfilePath}) && test -f \"\$resolved_path\" && printf '%s' \"\$resolved_path\""), 'hidden' => true, 'save' => 'dockerfile_check_'.$serviceName, ]); - if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') { + $resolvedDockerfilePath = str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString(); + if (! str_starts_with($resolvedDockerfilePath, "{$this->workdir}/")) { $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); continue; } + $fullDockerfilePath = escapeshellarg($resolvedDockerfilePath); $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"), + executeInDocker($this->deployment_uuid, "cat {$fullDockerfilePath}"), 'hidden' => true, 'save' => 'dockerfile_content_'.$serviceName, ]); @@ -4715,6 +4943,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $argsToAdd = collect([]); foreach ($variables as $key => $value) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile'); $argsToAdd->push("ARG {$key}"); } @@ -4777,7 +5006,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($totalAdded > 0) { $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"), + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$fullDockerfilePath} > /dev/null"), 'hidden' => true, ]); @@ -4788,13 +5017,68 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { - $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; $this->modify_dockerfile_for_secrets($fullDockerfilePath); $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); } } } + private function validateComposeBuildPaths(array|Collection $composeFile): void + { + foreach (data_get($composeFile, 'services', []) as $service) { + if (isset($service['build'])) { + $this->resolveComposeDockerfilePath($service['build']); + } + } + } + + private function resolveComposeDockerfilePath(mixed $build): string + { + if (! is_string($build) && ! is_array($build)) { + throw new \RuntimeException('Invalid Docker Compose build definition.'); + } + + $context = is_string($build) ? $build : data_get($build, 'context', '.'); + $dockerfile = is_array($build) ? data_get($build, 'dockerfile', 'Dockerfile') : 'Dockerfile'; + + if (! is_string($context) || ! is_string($dockerfile)) { + throw new \RuntimeException('Invalid Docker Compose build path: context and dockerfile must be strings.'); + } + + $this->validateComposeBuildPath($context, 'context'); + $this->validateComposeBuildPath($dockerfile, 'dockerfile'); + + return $this->normalizeComposeBuildPath("{$context}/{$dockerfile}", 'dockerfile'); + } + + private function validateComposeBuildPath(string $path, string $fieldName): void + { + if ($path === '' || str_starts_with($path, '/') || ! preg_match('/^[a-zA-Z0-9._\-\/@+]+$/', $path)) { + throw new \RuntimeException("Invalid Docker Compose build.{$fieldName} path."); + } + } + + private function normalizeComposeBuildPath(string $path, string $fieldName): string + { + $segments = []; + foreach (explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + if ($segment === '..') { + if ($segments === []) { + throw new \RuntimeException("Invalid Docker Compose build.{$fieldName} path: path traversal outside the repository."); + } + array_pop($segments); + + continue; + } + $segments[] = $segment; + } + + return $segments === [] ? '.' : implode('/', $segments); + } + private function add_build_secrets_to_compose($composeFile) { // Generate env variables if not already done @@ -4811,6 +5095,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $secrets = []; foreach ($variables as $key => $value) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Compose build secret environment'); $secrets[$key] = [ 'environment' => $key, ]; @@ -4827,7 +5112,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (! isset($service['build']['secrets'])) { $service['build']['secrets'] = []; } - foreach ($variables as $key => $value) { + foreach (array_keys($secrets) as $key) { if (! in_array($key, $service['build']['secrets'])) { $service['build']['secrets'][] = $key; } @@ -5092,11 +5377,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Reset restart count after successful deployment // This is done here (not in Livewire) to avoid race conditions // with GetContainersStatus reading old container restart counts - $this->application->update([ + $restartState = [ 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, - ]); + ]; + + if ($this->pull_request_id === 0) { + $restartState['restart_limit_reached'] = false; + } + + if ($this->pull_request_id === 0) { + $this->application->update($restartState); + } else { + $this->preview?->resetRestartLimit(); + } try { $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue); diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php index 1a7ceaeabc..c013da25a6 100644 --- a/app/Jobs/CheckDomainDnsJob.php +++ b/app/Jobs/CheckDomainDnsJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Actions\Shared\CheckDomainDns; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceApplication; use Illuminate\Bus\Queueable; @@ -23,7 +24,7 @@ class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue public int $timeout = 30; public function __construct( - public Application|ServiceApplication $resource, + public Application|ApplicationPreview|ServiceApplication $resource, public string $statusKey, public string $url, public ?Server $server, diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 8da2426da7..b4cb7fe705 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -73,6 +73,8 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue // Invalidate cache to ensure fresh data is loaded invalidate_versions_cache(); + CheckTraefikVersionJob::dispatch(); + // Only mark new version available if Coolify version actually increased if (version_compare($latest_version, $current_version, '>')) { // New version available diff --git a/app/Jobs/CheckMissingDatabaseBackupsJob.php b/app/Jobs/CheckMissingDatabaseBackupsJob.php new file mode 100644 index 0000000000..06ba79a33f --- /dev/null +++ b/app/Jobs/CheckMissingDatabaseBackupsJob.php @@ -0,0 +1,59 @@ +with(['team', 'database', 'latest_log']) + ->where('enabled', true) + ->where('missing_backup_notification_days', '>', 0) + ->chunkById(100, function ($backups): void { + foreach ($backups as $backup) { + $this->notifyIfMissing($backup); + } + }); + } + + private function notifyIfMissing(ScheduledDatabaseBackup $backup): void + { + $lastExecutionAt = $backup->last_execution_at ?? $backup->latest_log?->created_at; + $lastActivityAt = $lastExecutionAt ?? $backup->created_at; + + if (! $lastActivityAt || $lastActivityAt->isAfter(now()->subDays($backup->missing_backup_notification_days))) { + return; + } + + if ($backup->missing_backup_notification_sent_at?->greaterThanOrEqualTo($lastActivityAt)) { + return; + } + + if (! $backup->team) { + Log::warning("Cannot send missing backup notification for backup {$backup->id}: team not found"); + + return; + } + + if ($backup->team->getEnabledChannels('backup_failure') === []) { + return; + } + + $backup->team->notify(new BackupMissing($backup, $lastExecutionAt)); + $backup->forceFill(['missing_backup_notification_sent_at' => now()])->save(); + } +} diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 054a739bc6..3ef5d2c127 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; @@ -20,6 +22,8 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 60; + private ?array $previousOutdatedInfo = null; + /** * Create a new job instance. */ @@ -33,8 +37,14 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue */ public function handle(): void { + $this->server->refresh(); + $this->previousOutdatedInfo = $this->server->traefik_outdated_info; $this->clearOutdatedInfo(); + if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) { + return; + } + // Detect current version (makes SSH call) $currentVersion = getTraefikVersionFromDockerCompose($this->server); @@ -99,12 +109,10 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue // Always check for newer branches first $newerBranchInfo = $this->getNewerBranchInfo($currentBranch); - if (version_compare($current, $latest, '<')) { - // Patch update available - $this->storeOutdatedInfo($current, $latest, 'patch_update', null, $newerBranchInfo); - } elseif ($newerBranchInfo) { - // Only newer branch available (no patch update) + if ($newerBranchInfo) { $this->storeOutdatedInfo($current, $newerBranchInfo['latest'], 'minor_upgrade', $newerBranchInfo['target']); + } elseif (version_compare($current, $latest, '<')) { + $this->storeOutdatedInfo($current, $latest, 'patch_update'); } else { // Fully up to date $this->server->update(['traefik_outdated_info' => null]); @@ -116,7 +124,10 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue private function clearOutdatedInfo(): void { - $this->server->update(['traefik_outdated_info' => null]); + $this->server->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); } /** @@ -148,10 +159,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue } /** - * Store outdated information in database and send immediate notification. + * Store outdated information and notify for minor or major upgrades. */ - private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void + private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null): void { + $previousOutdatedInfo = $this->previousOutdatedInfo ?? $this->server->traefik_outdated_info; $outdatedInfo = [ 'current' => $current, 'latest' => $latest, @@ -164,16 +176,16 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue $outdatedInfo['upgrade_target'] = $upgradeTarget; } - // If there's a newer branch available (even for patch updates), include that info - if ($newerBranchInfo) { - $outdatedInfo['newer_branch_target'] = $newerBranchInfo['target']; - $outdatedInfo['newer_branch_latest'] = $newerBranchInfo['latest']; - } - $this->server->update(['traefik_outdated_info' => $outdatedInfo]); - // Send immediate notification to the team - $this->sendNotification($outdatedInfo); + $isRepeatedUpgrade = ($previousOutdatedInfo['type'] ?? null) === $type + && ($previousOutdatedInfo['upgrade_target'] ?? null) === $upgradeTarget; + + if ($type !== 'patch_update' && ! $isRepeatedUpgrade) { + $this->sendNotification($outdatedInfo); + } + + $this->previousOutdatedInfo = $outdatedInfo; } /** diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index ac94aa23f5..0a9eeba005 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + Server::query() + ->where(function ($query) { + $query->whereNull('proxy') + ->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value); + }) + ->where(function ($query) { + $query->whereNotNull('detected_traefik_version') + ->orWhereNotNull('traefik_outdated_info'); + }) + ->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); + // Load versions from cached data $traefikVersions = get_traefik_versions(); diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index f1635d6d4d..52b4064feb 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -19,6 +19,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S public function __construct(public Server $server) {} + private static function helperContainersCommand(): string + { + return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\''; + } + public function handle(): void { try { @@ -36,7 +41,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S 'active_deployment_uuids' => $activeDeployments, ]); - $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false); + $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); $helperContainers = collect(json_decode($containers)); if ($helperContainers->count() > 0) { diff --git a/app/Jobs/ConfigureDnsRecordJob.php b/app/Jobs/ConfigureDnsRecordJob.php new file mode 100644 index 0000000000..4e0ea64718 --- /dev/null +++ b/app/Jobs/ConfigureDnsRecordJob.php @@ -0,0 +1,87 @@ +with('integrationToken') + ->whereKey($this->zoneId) + ->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->teamId)) + ->firstOrFail(); + + try { + $provider->createRecord($zone, $this->hostname, $this->content, $this->resource()); + + DnsRecordConfigurationFinished::dispatch( + $this->teamId, + $this->resourceType, + $this->resourceId, + $this->hostname, + true, + $zone->integrationToken->name, + "DNS record added for {$this->hostname}.", + ); + } catch (Throwable $exception) { + DnsRecordConfigurationFinished::dispatch( + $this->teamId, + $this->resourceType, + $this->resourceId, + $this->hostname, + false, + $zone->integrationToken->name, + $exception->getMessage(), + ); + } + } + + public function failed(?Throwable $exception): void + { + DnsRecordConfigurationFinished::dispatch( + $this->teamId, + $this->resourceType, + $this->resourceId, + $this->hostname, + false, + '', + 'The DNS zone is no longer available.', + ); + } + + private function resource(): ?Model + { + $resourceClass = $this->resourceType === null ? null : (Relation::getMorphedModel($this->resourceType) ?? $this->resourceType); + if ($resourceClass === null || $this->resourceId === null || ! is_subclass_of($resourceClass, Model::class)) { + return null; + } + + return $resourceClass::query()->find($this->resourceId); + } +} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 0b73ed0cf5..3bc86df903 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -18,6 +18,7 @@ use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; use App\Rules\SafeWebhookUrl; +use App\Services\ScheduledJobDeliveryService; use App\Support\BackupCompression; use App\Support\ClickhouseBackupCommand; use Carbon\Carbon; @@ -78,7 +79,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?string $backup_log_uuid = null; - public function __construct(public ScheduledDatabaseBackup $backup) + public function __construct(public ScheduledDatabaseBackup $backup, public ?string $occurrenceUuid = null) { $this->onQueue(crons_queue()); $this->timeout = $backup->timeout ?? 3600; @@ -93,6 +94,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + if ($this->occurrenceUuid && ! app(ScheduledJobDeliveryService::class)->claim($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid)) { + return; + } + + $failed = false; + try { $databasesToBackup = null; @@ -322,6 +329,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_postgresql($database); } elseif (str($databaseType)->contains('mongo')) { if ($database === '*') { @@ -343,6 +351,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { $this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -357,6 +366,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { $this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -371,6 +381,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mariadb($database); } elseif ($this->database instanceof StandaloneClickhouse) { $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; @@ -382,6 +393,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_clickhouse($database); } else { throw new \Exception('Unsupported database type'); @@ -478,16 +490,21 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->removeExpiredBackups(); } } catch (Throwable $e) { + $failed = true; throw $e; } finally { - if ($this->team) { - BackupCreated::dispatch($this->team->id); + if (! $failed && $this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); } + if ($this->backup_log) { $this->backup_log->update([ 'finished_at' => Carbon::now()->toImmutable(), ]); } + if ($this->team) { + BackupCreated::dispatch($this->team->id); + } } } @@ -847,6 +864,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public function failed(?Throwable $exception): void { + if ($this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->fail($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); + } + Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [ 'job' => 'DatabaseBackupJob', 'backup_id' => $this->backup->uuid, diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index dff7d88de1..d07f346e56 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -26,7 +26,6 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -47,9 +46,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { if ($this->resource instanceof ApplicationPreview) { - DB::transaction(function (): void { - $this->deleteApplicationPreview(); - }); + $this->deleteApplicationPreview(); return; } @@ -99,17 +96,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue ]); } - DB::transaction(function (): void { - try { - $this->deleteScheduledVolumeBackups(); - } catch (\Throwable $e) { - Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ - 'resource_id' => $this->resource->id, - 'resource_type' => $this->resource->type(), - 'error' => $e->getMessage(), - ]); - } + try { + $this->deleteScheduledVolumeBackups(); + } catch (\Throwable $e) { + Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ + 'resource_id' => $this->resource->id, + 'resource_type' => $this->resource->type(), + 'error' => $e->getMessage(), + ]); + } + DB::transaction(function (): void { if ($this->resource instanceof Service) { app(DeleteService::class)->deleteLocal($this->resource); @@ -130,7 +127,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->resource->forceDelete(); }); - Artisan::queue('cleanup:stucked-resources'); } private function isDatabase(): bool @@ -163,10 +159,22 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue } } - private function deleteApplicationPreview() + private function deleteApplicationPreview(): void { $application = $this->resource->application; - $server = $application->destination->server; + + if (! $application) { + $this->deleteApplicationPreviewLocally(); + + return; + } + + $server = $application->destination?->server; + if (! $server) { + $this->deleteApplicationPreviewLocally(); + + return; + } $pull_request_id = $this->resource->pull_request_id; // Ensure the preview is soft deleted (may already be done in Livewire component) @@ -239,6 +247,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->resource->forceDelete(); } + private function deleteApplicationPreviewLocally(): void + { + DB::transaction(function (): void { + $this->resource->persistentStorages()->delete(); + ApplicationPreview::withoutEvents(fn () => $this->resource->forceDelete()); + }); + } + private function stopPreviewContainers(array $containers, $server, int $timeout = 30) { if (empty($containers)) { diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 5a7627e0a9..4c744a6e64 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -8,6 +8,7 @@ use App\Models\DockerCleanupExecution; use App\Models\Server; use App\Notifications\Server\DockerCleanupFailed; use App\Notifications\Server\DockerCleanupSuccess; +use App\Services\ScheduledJobDeliveryService; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -38,13 +39,20 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue public Server $server, public bool $manualCleanup = false, public bool $deleteUnusedVolumes = false, - public bool $deleteUnusedNetworks = false + public bool $deleteUnusedNetworks = false, + public ?string $occurrenceUuid = null, ) { $this->onQueue('high'); } public function handle(): void { + if ($this->occurrenceUuid && ! app(ScheduledJobDeliveryService::class)->claim($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid)) { + return; + } + + $failed = false; + try { $this->execution_log = DockerCleanupExecution::create([ 'server_id' => $this->server->id, @@ -138,6 +146,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue event(new DockerCleanupDone($this->execution_log)); } } catch (\Throwable $e) { + $failed = true; if ($this->execution_log) { $this->execution_log->update([ 'status' => 'failed', @@ -148,6 +157,10 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$e->getMessage())); throw $e; } finally { + if (! $failed && $this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); + } + if ($this->execution_log) { $this->execution_log->update([ 'finished_at' => Carbon::now()->toImmutable(), @@ -158,6 +171,10 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue public function failed(?\Throwable $exception): void { + if ($this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->fail($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); + } + $execution = DockerCleanupExecution::query() ->where('server_id', $this->server->id) ->where('status', 'running') diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9c4a2531a9..ef83d19446 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -2,11 +2,14 @@ namespace App\Jobs; +use App\Actions\Application\StopApplication; +use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Actions\Server\StartLogDrain; +use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; use App\Models\Application; use App\Models\ApplicationPreview; @@ -23,8 +26,10 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\SwarmDocker; +use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Notifications\Container\ContainerRestarted; use App\Services\ContainerStatusAggregator; +use App\Services\RestartCountTracker; use App\Traits\CalculatesExcludedStatus; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -95,8 +100,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public Collection $applicationContainerStatuses; + public Collection $applicationContainerRestartCounts; + public Collection $serviceContainerStatuses; + public Collection $previewContainerRestartCounts; + + public Collection $serviceContainerRestartCounts; + public bool $foundProxy = false; public bool $foundLogDrainContainer = false; @@ -122,7 +133,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundApplicationPreviewsIds = collect(); $this->foundServiceDatabaseIds = collect(); $this->applicationContainerStatuses = collect(); + $this->applicationContainerRestartCounts = collect(); $this->serviceContainerStatuses = collect(); + $this->previewContainerRestartCounts = collect(); + $this->serviceContainerRestartCounts = collect(); $this->allApplicationIds = collect(); $this->allDatabaseUuids = collect(); $this->allTcpProxyUuids = collect(); @@ -140,7 +154,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced { // Defensive initialization for Collection properties to handle queue deserialization edge cases $this->serviceContainerStatuses ??= collect(); + $this->previewContainerRestartCounts ??= collect(); + $this->serviceContainerRestartCounts ??= collect(); $this->applicationContainerStatuses ??= collect(); + $this->applicationContainerRestartCounts ??= collect(); $this->foundApplicationIds ??= collect(); $this->foundDatabaseUuids ??= collect(); $this->foundServiceApplicationIds ??= collect(); @@ -231,6 +248,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if (! $coolify_managed) { continue; } + if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { + continue; + } $name = data_get($container, 'name'); if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { @@ -241,6 +261,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $pullRequestId = $labels->get('coolify.pullRequestId', '0'); try { if ($pullRequestId === '0') { + $application = $this->applicationsById->get((string) $applicationId); + if ($application && $application->container_present !== true) { + $application->update(['container_present' => true]); + } if ($this->allApplicationIds->contains($applicationId)) { $this->foundApplicationIds->push($applicationId); } @@ -251,6 +275,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); + $restartCount = data_get($container, 'restart_count'); + if (is_numeric($restartCount)) { + if (! $this->applicationContainerRestartCounts->has($applicationId)) { + $this->applicationContainerRestartCounts->put($applicationId, collect()); + } + $this->applicationContainerRestartCounts->get($applicationId)->put($containerName, (int) $restartCount); + } } } else { $previewKey = $applicationId.':'.$pullRequestId; @@ -258,6 +289,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundApplicationPreviewsIds->push($previewKey); } $this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus); + $restartCount = data_get($container, 'restart_count'); + if (is_numeric($restartCount)) { + $this->previewContainerRestartCounts->push([ + 'key' => $previewKey, + 'count' => (int) $restartCount, + ]); + } } } catch (\Exception $e) { } @@ -278,6 +316,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count')); } } elseif ($subType === 'database') { $this->foundServiceDatabaseIds->push($subId); @@ -289,6 +328,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count')); } } } else { @@ -302,9 +342,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundDatabaseUuids->push($uuid); // TCP proxy should only be started/managed when database is actually running if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) { - $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true); + $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: true); } else { - $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false); + $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: false); } } } @@ -317,6 +357,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->updateProxyStatus(); + Application::whereIn('id', $this->foundApplicationIds->unique()) + ->update(['container_present' => true]); + $this->updateNotFoundApplicationStatus(); $this->updateNotFoundApplicationPreviewStatus(); $this->updateNotFoundDatabaseStatus(); @@ -324,6 +367,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->updateAdditionalServersStatus(); + $this->trackPreviewRestartCounts(); + // Aggregate multi-container application statuses $this->aggregateMultiContainerStatuses(); @@ -349,11 +394,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'uuid', 'name', 'status', + 'container_present', 'build_pack', 'docker_compose_raw', + 'environment_id', 'destination_id', 'destination_type', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->withCount('additional_servers') ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) @@ -372,11 +424,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'uuid', 'name', 'status', + 'container_present', 'build_pack', 'docker_compose_raw', + 'environment_id', 'destination_id', 'destination_type', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->withCount('additional_servers') ->whereIn('id', $additionalApplicationIds) @@ -402,6 +461,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'pull_request_id', 'status', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->whereIn('application_id', $applicationIds) ->get(); @@ -417,7 +481,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'docker_compose_raw', ]) ->with([ - 'applications:id,service_id,status,last_online_at', + 'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', 'databases:id,service_id,status,last_online_at,is_public,name', ]) ->get(); @@ -495,6 +559,53 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced continue; } + $maxRestartCount = 0; + $restartCountsAvailable = $this->applicationContainerRestartCounts->has($applicationId); + if ($restartCountsAvailable) { + $maxRestartCount = $this->applicationContainerRestartCounts->get($applicationId)->max() ?? 0; + $restartState = (new RestartCountTracker)->evaluate( + previousRestartCount: $application->restart_count ?? 0, + observedRestartCount: $maxRestartCount, + maxRestartCount: $application->max_restart_count ?? 0, + ); + + if ($restartState['restart_count_changed']) { + $hasCrashRestarts = $restartState['restart_count'] > 0; + $application->update([ + 'restart_count' => $restartState['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, + ]); + } + + if ($restartState['restart_limit_reached']) { + $restartLimitClaimed = Application::query() + ->whereKey($application->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($restartLimitClaimed) { + $application->refresh(); + StopApplication::dispatch( + application: $application, + previewDeployments: false, + dockerCleanup: false, + resetRestartCount: false, + removeContainers: false, + ); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + } + } + } + + if ($application->stoppedAfterRestartLimit() && $containerStatuses->every( + fn (string $status): bool => str($status)->contains('exited') + )) { + $application->update(['status' => 'exited']); + + continue; + } + // Parse docker compose to check for excluded containers $dockerComposeRaw = data_get($application, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -519,7 +630,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Use ContainerStatusAggregator service for state machine logic // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true); // Update application status with aggregated result if ($aggregatedStatus && $application->status !== $aggregatedStatus) { @@ -560,6 +671,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced continue; } + $restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0; + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { + StopServiceApplication::dispatch($subResource, false, false); + $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); + + continue; + } + // Parse docker compose from service to check for excluded containers $dockerComposeRaw = data_get($service, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -581,10 +700,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } // Use ContainerStatusAggregator service for state machine logic - // NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0 // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $restartCount, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { @@ -627,8 +745,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Batch update: mark all not-found applications as exited (excluding already exited ones) Application::whereIn('id', $notFoundApplicationIds) - ->where('status', 'not like', 'exited%') - ->update(['status' => 'exited']); + ->update([ + 'status' => 'exited', + 'container_present' => false, + 'restart_limit_reached' => false, + ]); } private function updateNotFoundApplicationPreviewStatus() @@ -687,7 +808,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } } - private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) + private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, mixed $restartCount = null, bool $tcpProxy = false): void { $database = $this->databasesByUuid->get($databaseUuid); if (! $database) { @@ -697,6 +818,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $database->status = $containerStatus; $database->save(); } + if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); + } if (! $this->isCompleteSnapshot()) { return; } @@ -719,6 +847,30 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } } + private function storeServiceRestartCount(string $key, string $containerName, mixed $restartCount): void + { + if (! is_numeric($restartCount)) { + return; + } + if (! $this->serviceContainerRestartCounts->has($key)) { + $this->serviceContainerRestartCounts->put($key, collect()); + } + $this->serviceContainerRestartCounts->get($key)->put($containerName, (int) $restartCount); + } + + private function trackPreviewRestartCounts(): void + { + $this->previewContainerRestartCounts + ->groupBy('key') + ->each(function (Collection $counts, string $key): void { + $preview = $this->previewsByKey->get($key); + if ($preview?->trackRestartCount((int) $counts->max('count'))) { + StopApplicationPreview::dispatch($preview, false, false); + $preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview)); + } + }); + } + private function updateNotFoundDatabaseStatus() { $notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids); @@ -752,8 +904,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Batch update service applications if ($notFoundServiceApplicationIds->isNotEmpty()) { ServiceApplication::whereIn('id', $notFoundServiceApplicationIds) + ->where('restart_limit_reached', false) ->where('status', '!=', 'exited') - ->update(['status' => 'exited']); + ->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]); } // Batch update service databases diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index 6f49cf30be..ed2d1c4546 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -66,7 +66,10 @@ class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue caCert: $caCert->ssl_certificate, caKey: $caCert->ssl_private_key, ); - $regenerated->push($certificate); + $resource = $certificate->database; + if ($resource) { + $regenerated->push($resource); + } } catch (\Exception $e) { Log::error('Failed to regenerate SSL certificate: '.$e->getMessage()); } diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index c5eb8c7fc5..6f0c535db3 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -8,6 +8,7 @@ use App\Enums\ProxyTypes; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use App\Services\ProxyPortParser; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -36,13 +37,19 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue public function handle() { try { + $configuration = GetProxyConfiguration::run($this->server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + ProxyPortParser::fromConfiguration($configuration); + // Set status to restarting $this->server->proxy->status = 'restarting'; $this->server->proxy->force_stop = false; $this->server->save(); // Build combined stop + start commands for a single activity - $commands = $this->buildRestartCommands(); + $commands = $this->buildRestartCommands($configuration); // Create activity and dispatch immediately - returns Activity right away // The remote_process runs asynchronously, so UI gets activity ID instantly @@ -57,6 +64,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue $this->activity_id = $activity->id; ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id); + } catch (\InvalidArgumentException $e) { + return handleError($e); } catch (\Throwable $e) { // Set error status $this->server->proxy->status = 'error'; @@ -76,18 +85,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue * Build combined stop + start commands for proxy restart. * This creates a single command sequence that shows all logs in one activity. */ - private function buildRestartCommands(): array + private function buildRestartCommands(string $configuration): array { $proxyType = $this->server->proxyType(); $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; $proxy_path = $this->server->proxyPath(); $stopTimeout = 30; - // Get proxy configuration - $configuration = GetProxyConfiguration::run($this->server); - if (! $configuration) { - throw new \Exception('Configuration is not synced'); - } SaveProxyConfiguration::run($this->server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); $this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 156f08d01b..3bfb30c7c1 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -9,6 +9,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Team; +use App\Services\ScheduledJobDeliveryService; use Cron\CronExpression; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -102,6 +103,8 @@ class ScheduledJobManager implements ShouldQueue 'execution_time' => $this->executionTime->toIso8601String(), ]); + app(ScheduledJobDeliveryService::class)->publishPending(); + // Process scheduled backups and tasks together so neither type starves the other. try { $this->processScheduledBackupsAndTasks(); @@ -231,7 +234,7 @@ class ScheduledJobManager implements ShouldQueue continue; } - if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) { + if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server)) { $dueBackups[] = [ 'backup' => $backup, 'server' => $server, @@ -266,7 +269,7 @@ class ScheduledJobManager implements ShouldQueue continue; } - if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) { + if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server)) { $dueTasks[] = [ 'task' => $task, 'server' => $server, @@ -289,14 +292,21 @@ class ScheduledJobManager implements ShouldQueue $server = $precheckedServer ?? $backup->server(); $skipReason = $this->getBackupSkipReason($backup, $server); if ($skipReason !== null) { - $this->skippedCount++; - $this->logBackupSkip($backup, $skipReason); + if ($server === null || $this->recordSkippedOccurrence($backup->frequency, $server, "scheduled-backup:{$backup->id}")) { + $this->skippedCount++; + $this->logBackupSkip($backup, $skipReason); + } return; } - if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) { - DatabaseBackupJob::dispatch($backup); + if ($this->dispatchOccurrence( + $backup->frequency, + $server, + "scheduled-backup:{$backup->id}", + 'database-backup', + $backup->id, + )) { $this->dispatchedCount++; Log::channel('scheduled')->info('Backup dispatched', [ 'backup_id' => $backup->id, @@ -320,25 +330,35 @@ class ScheduledJobManager implements ShouldQueue $server = $precheckedServer ?? $task->server(); $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); if ($criticalSkip !== null) { - $this->skippedCount++; - $this->logTaskSkip($task, $criticalSkip, $server); + if ($server === null || $this->recordSkippedOccurrence($task->frequency, $server, "scheduled-task:{$task->id}")) { + $this->skippedCount++; + $this->logTaskSkip($task, $criticalSkip, $server); + } return; } - if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) { - return; - } - $runtimeSkip = $this->getTaskRuntimeSkipReason($task); if ($runtimeSkip !== null) { - $this->skippedCount++; - $this->logTaskSkip($task, $runtimeSkip, $server); + if ($this->recordSkippedOccurrence($task->frequency, $server, "scheduled-task:{$task->id}")) { + $this->skippedCount++; + $this->logTaskSkip($task, $runtimeSkip, $server); + } + + return; + } + + if (! $this->dispatchOccurrence( + $task->frequency, + $server, + "scheduled-task:{$task->id}", + 'scheduled-task', + $task->id, + )) { return; } - ScheduledTaskJob::dispatch($task); $this->dispatchedCount++; Log::channel('scheduled')->info('Task dispatched', [ 'task_id' => $task->id, @@ -419,30 +439,43 @@ class ScheduledJobManager implements ShouldQueue return; } + if (! $this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server)) { + return; + } + if (! $server->isFunctional()) { - $this->skippedCount++; - $this->logSkip('volume_backup', 'server_not_functional', [ - 'backup_id' => $backup->id, - 'team_id' => $backup->team_id, - 'server_id' => $server->id, - ]); + if ($this->recordSkippedOccurrence($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) { + $this->skippedCount++; + $this->logSkip('volume_backup', 'server_not_functional', [ + 'backup_id' => $backup->id, + 'team_id' => $backup->team_id, + 'server_id' => $server->id, + ]); + } return; } if (isCloud() && $backup->team_id !== 0 && ! data_get($backup, 'team.subscription.stripe_invoice_paid', false)) { - $this->skippedCount++; - $this->logSkip('volume_backup', 'subscription_unpaid', [ - 'backup_id' => $backup->id, - 'team_id' => $backup->team_id, - 'server_id' => $server->id, - ]); + if ($this->recordSkippedOccurrence($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) { + $this->skippedCount++; + $this->logSkip('volume_backup', 'subscription_unpaid', [ + 'backup_id' => $backup->id, + 'team_id' => $backup->team_id, + 'server_id' => $server->id, + ]); + } return; } - if ($this->shouldDispatch($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) { - VolumeBackupJob::dispatch($backup); + if ($this->dispatchOccurrence( + $backup->frequency, + $server, + "scheduled-volume-backup:{$backup->id}", + 'volume-backup', + $backup->id, + )) { $this->dispatchedCount++; Log::channel('scheduled')->info('Volume backup dispatched', [ 'backup_id' => $backup->id, @@ -536,27 +569,36 @@ class ScheduledJobManager implements ShouldQueue private function processDockerCleanup(Server $server): void { try { + $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); + if (! $this->isDueCandidateBeforeExpensiveChecks($frequency, $server)) { + return; + } + $skipReason = $this->getDockerCleanupSkipReason($server); if ($skipReason !== null) { - $this->skippedCount++; - $this->logSkip('docker_cleanup', $skipReason, [ - 'server_id' => $server->id, - 'server_name' => $server->name, - 'team_id' => $server->team_id, - ]); + if ($this->recordSkippedOccurrence($frequency, $server, "docker-cleanup:{$server->id}")) { + $this->skippedCount++; + $this->logSkip('docker_cleanup', $skipReason, [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); + } return; } - $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); - - if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) { - DockerCleanupJob::dispatch( - $server, - false, - $server->settings->delete_unused_volumes, - $server->settings->delete_unused_networks - ); + if ($this->dispatchOccurrence( + $frequency, + $server, + "docker-cleanup:{$server->id}", + 'docker-cleanup', + $server->id, + [ + 'delete_unused_volumes' => $server->settings->delete_unused_volumes, + 'delete_unused_networks' => $server->settings->delete_unused_networks, + ], + )) { $this->dispatchedCount++; Log::channel('scheduled')->info('Docker cleanup dispatched', [ 'server_id' => $server->id, @@ -618,40 +660,44 @@ class ScheduledJobManager implements ShouldQueue ], $context)); } - private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool - { - return shouldRunCronNow( - $this->normalizeFrequency($frequency), + private function dispatchOccurrence( + string $frequency, + Server $server, + string $scheduleKey, + string $jobType, + int $resourceId, + array $payload = [], + ): bool { + return app(ScheduledJobDeliveryService::class)->recordAndPublish( + $scheduleKey, + $frequency, $this->serverTimezone($server), - $dedupKey, + $jobType, + $resourceId, + $payload, $this->executionTime, ); } - private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool + private function recordSkippedOccurrence(string $frequency, Server $server, string $scheduleKey): bool + { + return app(ScheduledJobDeliveryService::class)->recordSkipped( + $scheduleKey, + $frequency, + $this->serverTimezone($server), + $this->executionTime, + ); + } + + private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server): bool { $cron = new CronExpression($this->normalizeFrequency($frequency)); $executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server)); - $lastDispatched = Cache::get($dedupKey); $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); - if ($lastDispatched === null) { - $isDue = $cron->isDue($executionTime); - - if (! $isDue) { - Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000); - } - - return $isDue; - } - - $shouldFire = $previousDue->gt(Carbon::parse($lastDispatched)); - - if (! $shouldFire) { - Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000); - } - - return $shouldFire; + return $previousDue->gte( + $executionTime->copy()->subMinutes(ScheduledJobDeliveryService::CATCH_UP_WINDOW_MINUTES) + ); } private function normalizeFrequency(string $frequency): string diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index f7bd5f933d..6435c1a64b 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -12,6 +12,7 @@ use App\Models\Service; use App\Models\Team; use App\Notifications\ScheduledTask\TaskFailed; use App\Notifications\ScheduledTask\TaskSuccess; +use App\Services\ScheduledJobDeliveryService; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -65,7 +66,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public string $server_timezone = 'UTC'; - public function __construct(ScheduledTask $task) + public function __construct(ScheduledTask $task, public ?string $occurrenceUuid = null) { $this->onQueue(crons_queue()); @@ -106,7 +107,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + if ($this->occurrenceUuid && ! app(ScheduledJobDeliveryService::class)->claim($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid)) { + return; + } + $startTime = Carbon::now(); + $failed = false; try { $this->initializeExecutionContext(); @@ -170,6 +176,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue // No valid container was found. throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); } catch (\Throwable $e) { + $failed = true; if ($this->task_log) { $this->task_log->update([ 'status' => 'failed', @@ -192,6 +199,10 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue // Re-throw to trigger Laravel's retry mechanism with backoff throw $e; } finally { + if (! $failed && $this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); + } + if ($this->team) { ScheduledTaskDone::dispatch($this->team->id); } @@ -229,6 +240,10 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue */ public function failed(?\Throwable $exception): void { + if ($this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->fail($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); + } + $this->team ??= Team::find($this->task->team_id); Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [ diff --git a/app/Jobs/SendVerificationEmailJob.php b/app/Jobs/SendVerificationEmailJob.php new file mode 100644 index 0000000000..b9d2c8e11e --- /dev/null +++ b/app/Jobs/SendVerificationEmailJob.php @@ -0,0 +1,29 @@ +onQueue('high'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $this->user->sendVerificationEmail(); + } +} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index fe7a20972c..97d211d247 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -100,7 +100,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue ]); if ($this->server->unreachable_count > 0) { - $this->server->update(['unreachable_count' => 0]); + // Direct assignment: unreachable_count is not mass-assignable, + // so update() would silently drop the reset. + $this->server->unreachable_count = 0; + $this->server->save(); } $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true); diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 67c222c24d..20b60b9fd2 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -166,11 +166,9 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue } } - $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime); - // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - - if ($shouldRestartSentinel) { + if ($server->isSentinelEnabled() + && shouldRunCronNow('0 * * * *', $serverTimezone, "sentinel-version-check:{$server->id}", $this->executionTime) + ) { CheckAndStartSentinelJob::dispatch($server); } @@ -195,7 +193,6 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue ServerPatchCheckJob::dispatch($server); } - // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index 6ddbfe145c..0f56476e25 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -74,7 +74,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } - Subscription::updateOrCreate( + $subscription = Subscription::updateOrCreate( ['team_id' => $teamId], [ 'stripe_subscription_id' => $subscriptionId, @@ -83,6 +83,12 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_past_due' => false, ] ); + logger()->info('Stripe subscription checkout completed.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => data_get($data, 'id'), + 'stripe_subscription_id' => $subscription->stripe_subscription_id, + ]); break; case 'invoice.paid': $customerId = data_get($data, 'customer'); @@ -218,7 +224,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } - Subscription::updateOrCreate( + $subscription = Subscription::firstOrCreate( ['team_id' => $teamId], [ 'stripe_subscription_id' => $subscriptionId, @@ -226,6 +232,11 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_invoice_paid' => false, ] ); + if (! $subscription->stripe_subscription_id && $subscription->stripe_customer_id === $customerId) { + $subscription->update(['stripe_subscription_id' => $subscriptionId]); + } elseif ($subscription->stripe_customer_id !== $customerId) { + throw new \RuntimeException("Stripe customer ID mismatch for team {$teamId}: stored {$subscription->stripe_customer_id}, event {$customerId}."); + } break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index af2588ddaf..987b53e7f6 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -202,6 +202,9 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue // Broadcast events to update UI ServerValidated::dispatch($this->server->team_id, $this->server->uuid); ServerReachabilityChanged::dispatch($this->server); + if ($this->server->isSentinelEnabled()) { + CheckAndStartSentinelJob::dispatch($this->server); + } } catch (\Throwable $e) { Log::error('ValidateAndInstallServer: Exception occurred', [ diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index b567a71b7f..fb7e3376b5 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -8,6 +8,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Rules\SafeWebhookUrl; +use App\Services\ScheduledJobDeliveryService; use App\Support\BackupCompression; use Carbon\Carbon; use Illuminate\Bus\Queueable; @@ -32,7 +33,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue private ?ScheduledVolumeBackupExecution $execution = null; - public function __construct(public ScheduledVolumeBackup $backup) + public function __construct(public ScheduledVolumeBackup $backup, public ?string $occurrenceUuid = null) { $this->onQueue(crons_queue()); $this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT; @@ -55,6 +56,11 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + if ($this->occurrenceUuid && ! app(ScheduledJobDeliveryService::class)->claim($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid)) { + return; + } + + $failed = false; $this->backup->loadMissing(['backupable.resource', 'team', 's3']); $server = $this->backup->server(); $target = $this->backup->backupable; @@ -73,6 +79,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; $backupLocation = $backupDirectory.'/'.$filename; $this->execution->update(['filename' => $backupLocation]); + $streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup; try { $source = $this->backup->sourcePath(); @@ -86,11 +93,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage); $archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume ."; - $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) - .' -v '.escapeshellarg($source.':/volume:ro') - .' '.escapeshellarg($image) - .' sh -c '.escapeshellarg($archiveScript) - .' > '.escapeshellarg($backupLocation); + if ($streamToS3) { + $this->execution->update(['local_storage_deleted' => true]); + $archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image); + $this->execution->update(['s3_cleanup_pending' => true]); + } else { + $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image) + .' sh -c '.escapeshellarg($archiveScript) + .' > '.escapeshellarg($backupLocation); + } if ($this->backup->stop_during_backup) { $containers = $this->containersUsingVolume($source, $server); @@ -104,21 +117,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - instant_remote_process([ + $archiveOutput = instant_remote_process(array_filter([ $verifySourceCommand, - 'mkdir -p '.escapeshellarg($backupDirectory), + $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory), $archiveCommand, - ], $server, timeout: $this->timeout, disableMultiplexing: true); + ]), $server, timeout: $this->timeout, disableMultiplexing: true); $this->execution->update([ 'stop_container_ids' => null, 'stop_recovery_pending' => false, ]); - $size = (int) instant_remote_process( - ['du -b '.escapeshellarg($backupLocation).' | cut -f1'], - $server, - disableMultiplexing: true, - ); + $size = $streamToS3 + ? (int) str($archiveOutput)->trim()->afterLast("\n")->toString() + : (int) instant_remote_process( + ['du -b '.escapeshellarg($backupLocation).' | cut -f1'], + $server, + disableMultiplexing: true, + ); if ($size <= 0) { throw new \RuntimeException('The storage backup archive is empty or was not created.'); @@ -127,9 +142,12 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $warning = null; $s3Uploaded = null; $s3CleanupPending = false; - $localStorageDeleted = false; + $localStorageDeleted = $streamToS3; - if ($this->backup->save_s3) { + if ($streamToS3) { + $s3Uploaded = true; + $this->execution->update(['s3_cleanup_pending' => false]); + } elseif ($this->backup->save_s3) { $s3CleanupPending = true; $this->execution->update(['s3_cleanup_pending' => true]); @@ -180,14 +198,25 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue ]); } } catch (Throwable $exception) { + $failed = true; $recoveryError = $this->recoverIncompleteBackup($this->execution); - $archiveDeleted = false; + $archiveDeleted = $streamToS3; - try { - deleteBackupsLocally($backupLocation, $server, throwError: true); - $archiveDeleted = true; - } catch (Throwable $cleanupException) { - $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + if ($streamToS3) { + $exception = new \RuntimeException( + 'S3-only streaming backup failed: '.$exception->getMessage() + .'. The S3 destination may not support streaming uploads. Enable local backups to use the local archive upload method.', + previous: $exception, + ); + } + + if (! $streamToS3) { + try { + deleteBackupsLocally($backupLocation, $server, throwError: true); + $archiveDeleted = true; + } catch (Throwable $cleanupException) { + $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + } } $s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending; @@ -195,12 +224,18 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $this->execution->update([ 'status' => 'failed', 'message' => $exception->getMessage().$recoveryError, - 'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation, + 'filename' => $streamToS3 + ? ($s3CleanupPending ? $backupLocation : null) + : ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation), 'local_storage_deleted' => $archiveDeleted, ]); throw $exception; } finally { + if (! $failed && $this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); + } + $this->execution->update(['finished_at' => now()]); BackupCreated::dispatch($team->id); } @@ -208,6 +243,10 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue public function failed(?Throwable $exception): void { + if ($this->occurrenceUuid) { + app(ScheduledJobDeliveryService::class)->fail($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); + } + $execution = $this->execution ?? $this->backup->executions() ->where('status', 'running') ->latest('id') @@ -338,6 +377,34 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string + { + $s3 = $this->backup->s3; + + if (! $s3) { + $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); + + throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); + } + + $s3->testConnection(shouldSave: true); + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) + ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) + ->implode(' '); + $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + $destination = 'temporary/'.$s3->bucket.$backupLocation; + $streamScript = 'set -o pipefail; mc alias set'.$resolveOptions.' temporary ' + .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret) + .' >/dev/null && ('.$archiveScript.' | mc pipe --quiet'.$resolveOptions.' '.escapeshellarg($destination).' >/dev/null)' + .' && mc stat --json'.$resolveOptions.' '.escapeshellarg($destination) + .' | sed -n '.escapeshellarg('s/.*"size":\([0-9][0-9]*\).*/\1/p'); + + return 'docker run --rm --name '.escapeshellarg($containerName) + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image) + .' sh -c '.escapeshellarg($streamScript); + } + private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void { if (! isDev()) { diff --git a/app/Listeners/CleanupDatabaseImport.php b/app/Listeners/CleanupDatabaseImport.php new file mode 100644 index 0000000000..a3a36b8342 --- /dev/null +++ b/app/Listeners/CleanupDatabaseImport.php @@ -0,0 +1,67 @@ + */ + public array $backoff = [5, 15, 30]; + + public function handle(DatabaseImportFinished $event): void + { + $commands = $this->commands($event->data); + $server = Server::query()->find($event->data['serverId'] ?? null); + + if ($server && $commands !== []) { + instant_remote_process($commands, $server); + } + } + + /** + * @param array $data + * @return list + */ + public function commands(array $data): array + { + $commands = []; + + if (filled($data['containerName'] ?? null)) { + $commands[] = 'docker rm -f '.escapeshellarg($data['containerName']).' 2>/dev/null || true'; + } + + if (isSafeTmpPath($data['serverTmpPath'] ?? null)) { + $commands[] = 'rm -f '.escapeshellarg($data['serverTmpPath']).' 2>/dev/null || true'; + } + + if (isSafeTmpPath($data['credentialTmpPath'] ?? null)) { + $commands[] = 'rm -f '.escapeshellarg($data['credentialTmpPath']).' 2>/dev/null || true'; + } + + if (filled($data['container'] ?? null)) { + foreach (['containerTmpPath', 'scriptPath'] as $key) { + if (isSafeTmpPath($data[$key] ?? null)) { + $commands[] = 'docker exec '.escapeshellarg($data['container']).' rm -f '.escapeshellarg($data[$key]).' 2>/dev/null || true'; + } + } + } + + return $commands; + } + + public function failed(DatabaseImportFinished $event, Throwable $exception): void + { + Log::error('Database import cleanup failed', [ + 'serverId' => $event->data['serverId'] ?? null, + 'containerName' => $event->data['containerName'] ?? null, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index 226d2e3329..f54f40ffd0 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -33,7 +33,7 @@ class Index extends Component if (session('impersonating')) { session()->forget('impersonating'); $user = User::find(0); - $team_to_switch_to = $user->teams->first(); + $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first(); Auth::login($user); refreshSession($team_to_switch_to); @@ -69,7 +69,7 @@ class Index extends Component if (! $user) { abort(404); } - $team_to_switch_to = $user->teams->first(); + $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first(); Auth::login($user); refreshSession($team_to_switch_to); diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php new file mode 100644 index 0000000000..be5d603eeb --- /dev/null +++ b/app/Livewire/Analytics.php @@ -0,0 +1,606 @@ + uuid => name, for the server filter */ + public array $serverOptions = []; + + /** @var array uuid => name, for the application filter (scoped to the selected server) */ + public array $appOptions = []; + + /** + * Listbox options for the application filter, grouped under project headers so + * it's clear which application belongs to which project. + * + * @var array + */ + public array $appGroupedOptions = []; + + #[Url(as: 'range')] + public string $range = '24h'; + + #[Url(as: 'server')] + public string $serverUuid = ''; + + #[Url(as: 'app')] + public string $appUuid = ''; + + // Realtime refresh; off by default (click "Live" to arm it). Only meaningful on the + // 24h range, which matches the 60s Sentinel cache TTL. + public bool $live = false; + + public ?array $overview = null; + + public bool $latencyApproximate = false; + + public bool $uniquesApproximate = false; + + /** @var array> */ + public array $topApps = []; + + /** @var array> */ + public array $topHosts = []; + + /** @var array> */ + public array $topPaths = []; + + /** @var array>> */ + public array $breakdowns = []; + + public ?string $attribution = null; + + /** + * Per-bucket status-class time series for the stacked area chart, summed across + * target servers and sorted by bucket. Empty when no target Sentinel exposes the + * series endpoint (older builds), which flips the chart back to the status donut. + * + * @var array + */ + public array $series = []; + + public bool $hasSeries = false; + + /** + * Servers that could run traffic analytics but have it off — drives the nudge banner. + * + * @var array + */ + public array $eligibleDisabledServers = []; + + public string $nudgeKey = ''; + + /** + * Upper bound on per-app overviews fetched for the leaderboard, so a server with a huge + * number of recorded apps can't reintroduce a per-app round-trip storm. Truncation is + * logged (see loadData) rather than silently swallowed. + */ + private const MAX_LEADERBOARD_APPS = 200; + + /** @var array */ + protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; + + /** + * Per-request cache of app uuid => display metadata, so resolving a name/domain/link + * for the leaderboard and path domains hits the DB at most once per app. + * + * @var array + */ + protected array $appMetaCache = []; + + public function mount(?string $scopedServerUuid = null): void + { + $allServers = Server::ownedByCurrentTeamCached(); + + $this->scopedServerUuid = $scopedServerUuid; + + if ($this->scopedServerUuid !== null) { + $server = $allServers->firstWhere('uuid', $this->scopedServerUuid); + abort_if($server === null, 404); + + $this->serverUuid = $server->uuid; + $this->chartId = 'server-analytics-'.$server->uuid; + $this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect(); + $this->serverOptions = [$server->uuid => $server->name]; + $this->eligibleDisabledServers = []; + $this->nudgeKey = ''; + } else { + $this->servers = $allServers + ->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled()) + ->values(); + + $this->serverOptions = $this->servers + ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) + ->all(); + + $eligibleDisabled = $allServers + ->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled() + && ! $server->isSwarm() + && ! $server->isBuildServer()) + ->values(); + + $this->eligibleDisabledServers = $eligibleDisabled + ->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name]) + ->all(); + $this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12); + + // A bookmarked ?server= may point at a server that is no longer enabled. + if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { + $this->serverUuid = ''; + } + } + + $this->refreshAppOptions(); + + if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) { + $this->appUuid = ''; + } + + if ($this->servers->isNotEmpty()) { + $this->loadData(); + } + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + public function toggleLive(): void + { + if ($this->range !== '24h') { + return; + } + $this->live = ! $this->live; + } + + #[On('trafficAnalyticsStateChanged')] + public function refreshTrafficAnalyticsState(): void + { + if ($this->scopedServerUuid === null) { + return; + } + + $server = Server::ownedByCurrentTeam()->whereUuid($this->scopedServerUuid)->firstOrFail(); + $this->overview = null; + $this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect(); + + if ($this->servers->isNotEmpty()) { + $this->refreshAppOptions(); + $this->loadData(); + } + } + + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + + public function updatedServerUuid(): void + { + // Scope the app options to the newly selected server and drop an app filter + // that no longer belongs to it. + $this->refreshAppOptions(); + + if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) { + $this->appUuid = ''; + } + + $this->loadData(); + } + + public function updatedAppUuid(): void + { + $this->loadData(); + } + + protected function refreshAppOptions(): void + { + $enabledUuids = $this->servers->pluck('uuid'); + + $apps = Application::ownedByCurrentTeam()->with(['environment.project', 'destination.server'])->get() + ->filter(function (Application $app) use ($enabledUuids): bool { + $serverUuid = $app->destination?->server?->uuid; + + if (! $serverUuid || ! $enabledUuids->contains($serverUuid)) { + return false; + } + + return $this->serverUuid === '' || $serverUuid === $this->serverUuid; + }); + + // Flat uuid => name map, used to validate a bookmarked ?app= filter. + $options = $apps->mapWithKeys(fn (Application $app) => [$app->uuid => $app->name])->all(); + asort($options); + $this->appOptions = $options; + + // Grouped listbox options: a header row per project, then its apps (both alpha-sorted). + $grouped = []; + $byProject = $apps + ->groupBy(fn (Application $app) => (string) (data_get($app, 'environment.project.name') ?: 'Ungrouped')) + ->sortKeys(); + + foreach ($byProject as $projectName => $projectApps) { + $grouped[] = ['value' => '__group_'.md5($projectName), 'label' => $projectName, 'header' => true]; + foreach ($projectApps->sortBy('name') as $app) { + $grouped[] = ['value' => $app->uuid, 'label' => $app->name]; + } + } + + $this->appGroupedOptions = $grouped; + } + + /** + * Servers this view should query, honoring the active server/app filters. + */ + protected function targetServers(): Collection + { + if ($this->scopedServerUuid !== null) { + return $this->servers; + } + + if ($this->appUuid !== '') { + $server = Application::ownedByCurrentTeam()->whereUuid($this->appUuid)->first() + ?->destination?->server; + + return $server && $this->servers->contains(fn (Server $s) => $s->uuid === $server->uuid) + ? collect([$server]) + : collect(); + } + + if ($this->serverUuid !== '') { + return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values(); + } + + return $this->servers; + } + + public function loadData(): void + { + if ($this->servers->isEmpty()) { + return; + } + + [$from, $to] = $this->window(); + $appKey = $this->appUuid !== '' ? $this->appUuid : null; + $servers = $this->targetServers(); + + $overviews = []; + $appRows = []; + $pathTotals = []; + $breakdownTotals = array_fill_keys($this->breakdownDimensions, []); + $seriesByBucket = []; + $attribution = null; + + foreach ($servers as $server) { + try { + $client = $this->trafficClient($server); + + // Warm every server-wide endpoint in one docker exec instead of ~15 serial + // SSH round-trips; the per-call methods below then read from cache. + $leaderboardUuids = $client->prefetchServerWide($appKey, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); + + // Per-application leaderboard only makes sense when not already filtered to one app. + if ($appKey === null && $leaderboardUuids !== []) { + if (count($leaderboardUuids) > self::MAX_LEADERBOARD_APPS) { + Log::warning('Traffic analytics leaderboard truncated', [ + 'server' => $server->uuid, + 'total' => count($leaderboardUuids), + 'shown' => self::MAX_LEADERBOARD_APPS, + ]); + $leaderboardUuids = array_slice($leaderboardUuids, 0, self::MAX_LEADERBOARD_APPS); + } + // Warm the leaderboard's per-app overviews in a second batched exec. + $client->prefetchAppOverviews($leaderboardUuids, $from, $to); + } + + $overviews[] = $client->overview($appKey, $from, $to); + + if ($appKey === null) { + foreach ($leaderboardUuids as $uuid) { + $appOverview = $client->overview($uuid, $from, $to)->toArray(); + $meta = $this->appMeta($uuid); + + $appRows[] = [ + 'uuid' => $uuid, + 'name' => $meta['name'], + 'domain' => $meta['domain'], + 'link' => $meta['link'], + 'requests' => (int) ($appOverview['requests'] ?? 0), + 'bandwidth' => (int) ($appOverview['bytesIn'] ?? 0) + (int) ($appOverview['bytesOut'] ?? 0), + ]; + } + } + + foreach ($client->paths($appKey, $from, $to, 50) as $path) { + $data = $path->toArray(); + $pathStr = (string) ($data['path'] ?? ''); + // Prefer the per-path app from Sentinel; fall back to the active app filter + // (older Sentinel omits `app`, but a filtered view still knows the app). + $appId = (string) ($data['app'] ?? ''); + $resolveId = $appId !== '' ? $appId : ($appKey ?? ''); + // Key by (app, path) so the same path under two apps stays two rows, each + // carrying its own domain. + $key = $resolveId."\n".$pathStr; + $domain = $resolveId !== '' ? ($this->appMeta($resolveId)['domain'] ?? null) : null; + + $pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0]; + $pathTotals[$key]['requests'] += (int) ($data['requests'] ?? 0); + $pathTotals[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + $pathTotals[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0); + $pathTotals[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0); + $pathTotals[$key]['p95'] = max($pathTotals[$key]['p95'], (float) ($data['p95'] ?? 0)); + } + + foreach ($this->breakdownDimensions as $dimension) { + foreach ($client->breakdown($appKey, $dimension, $from, $to, 50) as $row) { + $data = $row->toArray(); + $value = (string) ($data['value'] ?? ''); + + $breakdownTotals[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0]; + $breakdownTotals[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0); + $breakdownTotals[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + } + } + + $attribution ??= $client->attribution(); + + // Per-bucket status series; summed by bucket across servers. Isolated so a + // series hiccup (or an older Sentinel lacking the endpoint) never discards a + // server's other data — an empty result simply flips the chart to the donut. + try { + foreach ($client->series($appKey, $this->range) as $bucket) { + $data = $bucket->toArray(); + $ts = (int) ($data['bucket'] ?? 0); + + $seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0]; + $seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0); + $seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0); + $seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0); + $seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0); + $seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0); + $seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0); + $seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + // Uniques summed across servers (approximate); p95 takes the worst bucket. + $seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0); + $seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0)); + } + } catch (\Throwable $e) { + // Leave this server out of the series; donut fallback covers it. + } + } catch (\Throwable $e) { + // Skip unreachable/failed servers so one bad server doesn't break the whole view. + continue; + } + } + + if (empty($overviews)) { + $this->resetData(); + // The chart lives under wire:ignore, so it only updates via this event — dispatch + // even when cleared so a previously-populated chart flips to its no-data state + // instead of keeping stale data. + $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); + + return; + } + + $result = TrafficAnalyticsAggregator::sumOverviews($overviews); + $this->overview = $result['overview']->toArray(); + $this->latencyApproximate = $result['latencyApproximate']; + $this->uniquesApproximate = $result['uniquesApproximate']; + + usort($appRows, fn ($a, $b) => $b['requests'] <=> $a['requests']); + $this->topApps = array_slice($appRows, 0, 50); + + // Top hosts: fold per-app volume up to the served hostname (an app's primary + // domain). Apps without a configured FQDN collapse into one "Unknown host" row. + $hostTotals = []; + foreach ($appRows as $row) { + $host = $row['domain'] ?? ''; + $hostTotals[$host] ??= ['host' => $host, 'requests' => 0, 'bandwidth' => 0]; + $hostTotals[$host]['requests'] += (int) $row['requests']; + $hostTotals[$host]['bandwidth'] += (int) $row['bandwidth']; + } + $hosts = array_values($hostTotals); + usort($hosts, fn ($a, $b) => $b['requests'] <=> $a['requests']); + $this->topHosts = array_slice($hosts, 0, 50); + + $paths = array_values($pathTotals); + usort($paths, fn ($a, $b) => $b['requests'] <=> $a['requests']); + $this->topPaths = array_slice($paths, 0, 50); + + $breakdowns = []; + foreach ($this->breakdownDimensions as $dimension) { + $rows = array_values($breakdownTotals[$dimension]); + usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']); + if ($dimension === 'referer') { + $rows = groupRefererBreakdownRows($rows); + } + $breakdowns[$dimension] = array_slice($rows, 0, 50); + } + $this->breakdowns = $breakdowns; + + $this->attribution = $attribution; + + ksort($seriesByBucket); + $this->series = array_values($seriesByBucket); + $this->hasSeries = $this->series !== []; + + $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); + } + + /** + * Payload for the status chart: the stacked-area time series when available, + * plus the donut totals as a fallback for older Sentinel builds. + * + * @return array + */ + protected function chartPayload(): array + { + $device = $this->deviceChartData(); + $overview = $this->overview ?? []; + + return [ + 'hasSeries' => $this->hasSeries, + 'range' => $this->range, + 'seriesData' => [ + $overview['s2xx'] ?? 0, + $overview['s3xx'] ?? 0, + $overview['s4xx'] ?? 0, + $overview['s5xx'] ?? 0, + ], + 'timeSeries' => [ + 'categories' => array_column($this->series, 'bucket'), + 'requests' => $this->requestsSpark(), + 's2xx' => array_column($this->series, 's2xx'), + 's3xx' => array_column($this->series, 's3xx'), + 's4xx' => array_column($this->series, 's4xx'), + 's5xx' => array_column($this->series, 's5xx'), + ], + 'requestsSpark' => $this->requestsSpark(), + 'sparkCategories' => array_column($this->series, 'bucket'), + 'errorsSpark' => $this->errorsSpark(), + 'bandwidthSpark' => $this->bandwidthSpark(), + 'uniquesSpark' => $this->uniquesSpark(), + 'latencySpark' => $this->latencySpark(), + 'geo' => $this->geoMarkers(), + 'deviceLabels' => $device['labels'], + 'deviceSeries' => $device['series'], + ]; + } + + protected function resetData(): void + { + $this->overview = null; + $this->latencyApproximate = false; + $this->uniquesApproximate = false; + $this->topApps = []; + $this->topHosts = []; + $this->topPaths = []; + $this->breakdowns = []; + $this->attribution = null; + $this->series = []; + $this->hasSeries = false; + } + + public function errorRate(): float + { + if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { + return 0.0; + } + + $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); + + return round(($errors / $this->overview['requests']) * 100, 2); + } + + public function bandwidthBytes(): int + { + if (! $this->overview) { + return 0; + } + + return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0); + } + + protected function trafficClient(Server $server): SentinelTrafficClient + { + return app(SentinelTrafficClient::class, ['server' => $server]); + } + + /** + * Resolve an app uuid to its display name, primary domain, and analytics-page link, + * memoized per request. Returns the uuid as the name for apps not owned by the team + * so a Sentinel-reported uuid never discloses another team's application name. + * + * @return array{name: string, domain: ?string, link: ?string} + */ + protected function appMeta(string $uuid): array + { + if (isset($this->appMetaCache[$uuid])) { + return $this->appMetaCache[$uuid]; + } + + $app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($uuid)->first(); + + $domain = null; + if ($app) { + $first = collect($app->fqdns)->first(); + $domain = $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null; + } + + $link = null; + if ($app && data_get($app, 'environment.project.uuid')) { + $link = route('project.application.analytics', [ + 'project_uuid' => $app->environment->project->uuid, + 'environment_uuid' => $app->environment->uuid, + 'application_uuid' => $app->uuid, + ]); + } + + return $this->appMetaCache[$uuid] = [ + 'name' => $app?->name ?? $uuid, + 'domain' => $domain, + 'link' => $link, + ]; + } + + /** + * @return array{0: string, 1: string} + */ + private function window(): array + { + $to = now(); + $from = match ($this->range) { + '7d' => now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; + } + + public function placeholder(array $params = []): View + { + $scopedServerUuid = $params['scopedServerUuid'] ?? null; + $hideSkeleton = false; + + if (is_string($scopedServerUuid)) { + $server = Server::ownedByCurrentTeamCached()->firstWhere('uuid', $scopedServerUuid); + $hideSkeleton = $server !== null && ! $server->isTrafficAnalyticsEnabled(); + } + + // Rendered instantly; the Sentinel round-trips run in the deferred lazy-load request. + return view('livewire.analytics-placeholder', compact('hideSkeleton')); + } + + public function render() + { + return view('livewire.analytics'); + } +} diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 5582efbdae..7e8a68dfca 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -62,8 +62,6 @@ class Index extends Component public ?string $remoteServerUser = 'root'; - public bool $isSwarmManager = false; - public bool $isCloudflareTunnel = false; public ?Server $createdServer = null; @@ -112,6 +110,7 @@ class Index extends Component if ($this->selectedServerType === 'localhost' && $this->selectedExistingServer === 0) { $this->createdServer = Server::find(0); if ($this->createdServer) { + $this->authorize('update', $this->createdServer); $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); } } @@ -196,6 +195,7 @@ class Index extends Component if (! $this->createdServer) { return $this->dispatch('error', 'Localhost server is not found. Something went wrong during installation. Please try to reinstall or contact support.'); } + $this->authorize('update', $this->createdServer); $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); return $this->validateServer('localhost'); @@ -248,7 +248,8 @@ class Index extends Component return; } - $this->createdPrivateKey = PrivateKey::where('team_id', currentTeam()->id)->where('id', $this->selectedExistingPrivateKey)->first(); + $this->createdPrivateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->selectedExistingPrivateKey); + $this->authorize('view', $this->createdPrivateKey); $this->privateKey = $this->createdPrivateKey->private_key; $this->currentState = 'create-server'; } @@ -274,6 +275,8 @@ class Index extends Component public function savePrivateKey() { + $this->authorize('create', PrivateKey::class); + $this->validate([ 'privateKeyName' => 'required|string|max:255', 'privateKeyDescription' => 'nullable|string|max:255', @@ -281,7 +284,6 @@ class Index extends Component ]); try { - $this->authorize('create', PrivateKey::class); $privateKey = PrivateKey::createAndStore([ 'name' => $this->privateKeyName, 'description' => $this->privateKeyDescription, @@ -298,13 +300,9 @@ class Index extends Component public function saveServer() { - $this->validate(); + $this->authorize('create', Server::class); - try { - $this->authorize('create', Server::class); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->validate(); $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); @@ -315,6 +313,10 @@ class Index extends Component return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } + $privateKeyId = $this->createdPrivateKey?->id ?? $this->selectedExistingPrivateKey; + $this->createdPrivateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId); + $this->authorize('view', $this->createdPrivateKey); + $this->createdServer = Server::create([ 'name' => $this->remoteServerName, 'ip' => $this->remoteServerHost, @@ -324,7 +326,6 @@ class Index extends Component 'private_key_id' => $this->createdPrivateKey->id, 'team_id' => currentTeam()->id, ]); - $this->createdServer->settings->is_swarm_manager = $this->isSwarmManager; $this->createdServer->settings->is_cloudflare_tunnel = $this->isCloudflareTunnel; $this->createdServer->settings->save(); $this->selectedExistingServer = $this->createdServer->id; @@ -333,11 +334,14 @@ class Index extends Component public function installServer() { + $this->authorizeCreatedServer(); $this->dispatch('init', true); } public function validateServer() { + $this->authorizeCreatedServer(); + try { $this->disableSshMux(); @@ -385,6 +389,8 @@ class Index extends Component public function handlePrerequisitesInstalled() { + $this->authorizeCreatedServer(); + try { // Revalidate prerequisites after installation completes $validationResult = $this->createdServer->validatePrerequisites(); @@ -435,6 +441,8 @@ class Index extends Component public function selectProxy(?string $proxyType = null) { + $this->authorizeCreatedServer(); + if (! $proxyType) { return $this->getProjects(); } @@ -466,6 +474,8 @@ class Index extends Component public function createNewProject() { + $this->authorize('create', Project::class); + $this->createdProject = Project::create([ 'name' => 'My first project', 'team_id' => currentTeam()->id, @@ -476,6 +486,10 @@ class Index extends Component public function showNewResource() { + $this->authorizeCreatedServer(); + $this->createdProject = Project::ownedByCurrentTeam()->findOrFail($this->createdProject?->id); + $this->authorize('view', $this->createdProject); + $this->skipBoarding(); return redirect()->route( @@ -490,6 +504,8 @@ class Index extends Component public function saveAndValidateServer() { + $this->authorizeCreatedServer(); + $this->validate(array_intersect_key($this->rules(), array_flip([ 'remoteServerPort', 'remoteServerUser', @@ -516,6 +532,12 @@ class Index extends Component $configRepository->disableSshMux(); } + private function authorizeCreatedServer(): void + { + $this->createdServer = Server::findOrFail($this->createdServer?->id); + $this->authorize('update', $this->createdServer); + } + public function render() { return view('livewire.boarding.index')->layout('layouts.boarding'); diff --git a/app/Livewire/Concerns/BuildsTrafficChartPayload.php b/app/Livewire/Concerns/BuildsTrafficChartPayload.php new file mode 100644 index 0000000000..66af46efa5 --- /dev/null +++ b/app/Livewire/Concerns/BuildsTrafficChartPayload.php @@ -0,0 +1,125 @@ + + */ + public function requestsSpark(): array + { + return array_map( + fn ($b) => (int) ($b['s2xx'] ?? 0) + (int) ($b['s3xx'] ?? 0) + (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0), + $this->series, + ); + } + + /** + * Whether there is plottable request-over-time data for the Requests chart. False when + * Sentinel returned no series buckets (older builds) or every bucket is empty (no traffic + * in the range), so the views can render a no-data state instead of a blank chart. + */ + public function hasRequestSeries(): bool + { + return array_sum($this->requestsSpark()) > 0; + } + + /** + * Per-bucket error requests (4xx + 5xx), for the Error-rate spark. + * + * @return array + */ + public function errorsSpark(): array + { + return array_map( + fn ($b) => (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0), + $this->series, + ); + } + + /** + * Per-bucket bandwidth (bytes in + out), for the Bandwidth spark. Empty for + * older Sentinel builds that don't emit per-bucket byte counts. + * + * @return array + */ + public function bandwidthSpark(): array + { + return array_map( + fn ($b) => (int) ($b['bytesIn'] ?? 0) + (int) ($b['bytesOut'] ?? 0), + $this->series, + ); + } + + /** + * Per-bucket unique visitors, for the Visitors spark. + * + * @return array + */ + public function uniquesSpark(): array + { + return array_map(fn ($b) => (int) ($b['uniqueVisitors'] ?? 0), $this->series); + } + + /** + * Per-bucket p95 latency (ms), for the Latency spark. + * + * @return array + */ + public function latencySpark(): array + { + return array_map(fn ($b) => round((float) ($b['p95'] ?? 0), 1), $this->series); + } + + /** + * Per-country marker data for the globe: [{code, requests}] over known ISO-A2 rows. + * + * @return array + */ + protected function geoMarkers(): array + { + $out = []; + foreach (($this->breakdowns['country'] ?? []) as $row) { + $code = strtoupper((string) ($row['value'] ?? '')); + $requests = (int) ($row['requests'] ?? 0); + if (preg_match('/^[A-Z]{2}$/', $code) && $requests > 0) { + $out[] = ['code' => $code, 'requests' => $requests]; + } + } + + return $out; + } + + /** + * Device-donut data. Raw Sentinel device values are folded into friendly labels + * (pc → Desktop, smartphone → Mobile, …) and summed, then sorted by volume. + * + * @return array{labels: array, series: array} + */ + public function deviceChartData(): array + { + $totals = []; + foreach (($this->breakdowns['device'] ?? []) as $row) { + $value = (string) ($row['value'] ?? ''); + $label = $value === '__other__' ? 'Other' : deviceLabel($value); + $totals[$label] = ($totals[$label] ?? 0) + (int) ($row['requests'] ?? 0); + } + arsort($totals); + + return [ + 'labels' => array_keys($totals), + 'series' => array_map('intval', array_values($totals)), + ]; + } +} diff --git a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php index 44dba0d5e8..6ecea8e96f 100644 --- a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php +++ b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php @@ -209,19 +209,20 @@ trait InteractsWithCloudflareDomainConnect } } - // Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa). - try { - $settings = instanceSettings(); - $publicV4 = data_get($settings, 'public_ipv4'); - $publicV6 = data_get($settings, 'public_ipv6'); - if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $ipv4 = $publicV4; + if ($this->usesInstanceNetworkAddressesForDnsHints()) { + try { + $settings = instanceSettings(); + $publicV4 = data_get($settings, 'public_ipv4'); + $publicV6 = data_get($settings, 'public_ipv6'); + if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $ipv4 = $publicV4; + } + if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $ipv6 = $publicV6; + } + } catch (\Throwable) { + // } - if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $ipv6 = $publicV6; - } - } catch (\Throwable) { - // } return [$ipv4, $ipv6]; @@ -253,5 +254,7 @@ trait InteractsWithCloudflareDomainConnect return null; } + abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool; + abstract protected function authorizeUpdateForDomainConnect(): void; } diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php new file mode 100644 index 0000000000..2f7e86c7ff --- /dev/null +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -0,0 +1,267 @@ +authorizeDnsProviderChange(); + $this->loadDnsProviderProposals(); + if ($this->dnsProviderProposals === []) { + $this->dispatch('error', 'No connected DNS provider can manage the configured domains.'); + + return; + } + $this->showDnsProviderModal = true; + } + + public function closeDnsProviderModal(): void + { + $this->showDnsProviderModal = false; + } + + public function createManagedDnsRecord(string $hostname, int $zoneId, ?string $content = null): void + { + $this->authorizeDnsProviderChange(); + $cloudflare = app(CloudflareDnsProvider::class); + $zone = $this->findTeamZone($zoneId); + $content ??= $this->serverIp; + if ($zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) { + $this->dispatch('error', 'No connected DNS provider or public server IP is available for this domain.'); + + return; + } + try { + $cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname)); + $this->markDnsManaged($hostname, $zone->integrationToken->name); + $this->dispatch('success', "DNS record created for {$hostname}."); + $this->loadDnsProviderProposals(); + } catch (DnsRecordConflictException $e) { + $this->dnsProviderConflicts[$hostname.'|'.$zoneId] = [ + 'record_id' => $e->providerRecordId, 'current' => $e->currentValue, 'proposed' => $e->proposedValue, + ]; + } catch (\Throwable $e) { + $this->dispatch('error', $e->getMessage()); + } + } + + /** @param array $urls */ + protected function hasDnsProviderForUrls(array $urls): bool + { + $provider = app(CloudflareDnsProvider::class); + + return collect($urls)->contains(function (string $url) use ($provider): bool { + $hostname = parse_url($url, PHP_URL_HOST); + + return is_string($hostname) && $provider->findZones(currentTeam()->id, $hostname)->isNotEmpty(); + }); + } + + /** @param array $urls */ + protected function configureDnsAfterDomainAdd(array $urls): bool + { + $hostnames = collect($urls)->map(fn (string $url) => parse_url($url, PHP_URL_HOST)) + ->filter(fn ($hostname) => is_string($hostname))->map(fn (string $hostname) => strtolower($hostname)) + ->unique()->values()->all(); + $this->loadDnsProviderProposals($hostnames); + if ($this->dnsProviderProposals === []) { + return false; + } + if (blank($this->serverIp) || filter_var($this->serverIp, FILTER_VALIDATE_IP) === false) { + return false; + } + $this->markDnsPending($hostnames); + + $proposalsByHostname = collect($this->dnsProviderProposals)->groupBy('hostname'); + $canConfigureAutomatically = $proposalsByHostname->every(function ($proposals): bool { + if ($proposals->count() !== 1) { + return false; + } + + $zone = $this->findTeamZone((int) $proposals->first()['zone_id']); + + return $zone?->integrationToken->automaticDnsEnabled() === true; + }); + + if (! $canConfigureAutomatically) { + $this->showDnsProviderModal = true; + + return true; + } + + foreach ($this->dnsProviderProposals as $proposal) { + $zone = $this->findTeamZone((int) $proposal['zone_id']); + if ($zone === null) { + continue; + } + + $resource = $this->dnsResourceForHostname($proposal['hostname']); + ConfigureDnsRecordJob::dispatch( + currentTeam()->id, + $zone->id, + $resource?->getMorphClass(), + $resource?->getKey(), + $proposal['hostname'], + $this->serverIp, + ); + $this->dispatch('info', "Adding DNS record for {$proposal['hostname']}."); + } + + return true; + } + + public function openManualDnsRecords(): void + { + $this->authorizeDnsProviderChange(); + $this->loadDnsProviderProposals(); + $this->dispatch('open-dns-records-modal'); + } + + public function replaceManagedDnsRecord(string $hostname, int $zoneId, string $password = ''): void + { + $this->authorizeDnsProviderChange(); + $key = $hostname.'|'.$zoneId; + $conflict = $this->dnsProviderConflicts[$key] ?? null; + $zone = $this->findTeamZone($zoneId); + $content = $this->serverIp; + if ($conflict === null || $zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) { + $this->dispatch('error', 'The DNS conflict is no longer available. Check the record again.'); + + return; + } + try { + app(CloudflareDnsProvider::class)->replaceRecord( + $zone, + (string) ($conflict['record_id'] ?? ''), + $hostname, + $content, + $this->dnsResourceForHostname($hostname), + (string) ($conflict['current'] ?? ''), + ); + unset($this->dnsProviderConflicts[$key]); + $this->dispatch('success', "DNS record replaced for {$hostname}."); + $this->loadDnsProviderProposals(); + } catch (\Throwable $e) { + unset($this->dnsProviderConflicts[$key]); + $this->dispatch('error', $e->getMessage()); + } + } + + protected function loadDnsProviderProposals(?array $hostnames = null): void + { + $provider = app(CloudflareDnsProvider::class); + $hostnames ??= $this->allDomainHostnames(); + $managed = ManagedDnsRecord::query()->where('team_id', currentTeam()->id)->whereIn('name', $hostnames)->pluck('id', 'name'); + $this->dnsProviderProposals = collect($hostnames)->flatMap(fn (string $hostname) => $provider->findZones(currentTeam()->id, $hostname) + ->map(fn (DnsProviderZone $zone) => [ + 'hostname' => $hostname, 'zone_id' => $zone->id, 'zone' => $zone->name, + 'credential' => $zone->integrationToken->name, 'target' => (string) $this->serverIp, + 'managed' => $managed->has($hostname), + ])->all())->values()->all(); + } + + protected function markDnsPending(array $hostnames): void + { + foreach ($this->domainRows as $index => $row) { + $hostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST); + if (is_string($hostname) && in_array(strtolower($hostname), $hostnames, true)) { + $this->domainRows[$index]['dns_status'] = 'pending'; + $this->domainRows[$index]['dns_message'] = 'A connected DNS provider can create this record.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + $this->persistDomainDnsStatuses(); + } + + protected function markDnsManaged(string $hostname, string $credential): void + { + foreach ($this->domainRows as $index => $row) { + $rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST); + if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) { + $this->domainRows[$index]['dns_status'] = 'ok'; + $this->domainRows[$index]['dns_message'] = "DNS record created through {$credential}."; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + $this->persistDomainDnsStatuses(); + } + + public function dnsRecordConfigurationFinished(array $event): void + { + $resource = $this->dnsResourceForHostname($event['hostname']); + if ($resource === null || $resource->getMorphClass() !== $event['resourceType'] + || (string) $resource->getKey() !== (string) $event['resourceId']) { + return; + } + + if ($event['successful']) { + $this->markDnsManaged($event['hostname'], $event['credential']); + $this->dispatch('success', $event['message']); + + return; + } + + $this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}"); + } + + protected function deleteManagedDnsForUrl(string $url): void + { + $hostname = parse_url($url, PHP_URL_HOST); + if (! is_string($hostname)) { + return; + } + + $resource = $this->dnsResourceForHostname($hostname); + if ($resource === null) { + return; + } + + $record = ManagedDnsRecord::query() + ->where('team_id', currentTeam()->id) + ->where('name', strtolower($hostname)) + ->where('resource_type', $resource->getMorphClass()) + ->where('resource_id', $resource->getKey()) + ->first(); + + if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) { + auditLog('ui.dns_record.delete_skipped', [ + 'team_id' => currentTeam()->id, + 'hostname' => $hostname, + 'provider' => 'cloudflare', + 'reason' => 'remote_record_changed', + ], 'warning'); + $this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.'); + } + } + + protected function authorizeDnsProviderChange(): void + { + $this->authorize('update', property_exists($this, 'application') ? $this->application : $this->service); + } + + protected function findTeamZone(int $zoneId): ?DnsProviderZone + { + return DnsProviderZone::query()->whereKey($zoneId) + ->whereHas('integrationToken', fn ($query) => $query->where('team_id', currentTeam()->id))->first(); + } + + abstract protected function persistDomainDnsStatuses(): void; + + abstract protected function dnsResourceForHostname(string $hostname): ?Model; +} diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php new file mode 100644 index 0000000000..532460d454 --- /dev/null +++ b/app/Livewire/Dashboard/TrafficAnalytics.php @@ -0,0 +1,180 @@ + + */ + public array $series = []; + + public function mount(): void + { + $this->servers = Server::ownedByCurrentTeamCached() + ->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled()) + ->values(); + + if ($this->servers->isNotEmpty()) { + $this->loadData(); + } + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + public function loadData(): void + { + if ($this->servers->isEmpty()) { + return; + } + + [$from, $to] = $this->window(); + + $overviews = []; + $seriesByBucket = []; + + foreach ($this->servers as $server) { + try { + $client = $this->trafficClient($server); + + $overviews[] = $client->overview(null, $from, $to); + + // Per-bucket status series, summed across servers, for the sparklines. + // Isolated so a series hiccup (older Sentinel) never drops a server's overview. + try { + foreach ($client->series(null, $this->range) as $bucket) { + $data = $bucket->toArray(); + $ts = (int) ($data['bucket'] ?? 0); + + $seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0]; + $seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0); + $seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0); + $seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0); + $seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0); + $seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0); + $seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0); + $seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + $seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0); + $seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0)); + } + } catch (\Throwable $e) { + // Leave this server out of the sparkline series. + \Log::debug('Traffic series fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]); + } + } catch (\Throwable $e) { + // Skip unreachable/failed servers so one bad server doesn't break the whole summary. + \Log::debug('Traffic overview fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]); + + continue; + } + } + + if (empty($overviews)) { + // Every server's fetch failed; don't present an all-zero KPI panel as if it were real data. + $this->overview = null; + $this->latencyApproximate = false; + $this->uniquesApproximate = false; + $this->series = []; + + return; + } + + $result = TrafficAnalyticsAggregator::sumOverviews($overviews); + + $this->overview = $result['overview']->toArray(); + $this->latencyApproximate = $result['latencyApproximate']; + $this->uniquesApproximate = $result['uniquesApproximate']; + + ksort($seriesByBucket); + $this->series = array_values($seriesByBucket); + + $this->dispatch("refreshChartData-{$this->chartId}-status", [ + 'requestsSpark' => $this->requestsSpark(), + 'sparkCategories' => array_column($this->series, 'bucket'), + 'errorsSpark' => $this->errorsSpark(), + 'bandwidthSpark' => $this->bandwidthSpark(), + 'uniquesSpark' => $this->uniquesSpark(), + ]); + } + + public function errorRate(): float + { + if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { + return 0.0; + } + + $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); + + return round(($errors / $this->overview['requests']) * 100, 2); + } + + public function bandwidthBytes(): int + { + if (! $this->overview) { + return 0; + } + + return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0); + } + + protected function trafficClient(Server $server): SentinelTrafficClient + { + return app(SentinelTrafficClient::class, ['server' => $server]); + } + + /** + * @return array{0: string, 1: string} + */ + private function window(): array + { + $to = now(); + $from = match ($this->range) { + '7d' => now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; + } + + public function placeholder(): View + { + // Rendered instantly on the dashboard; Sentinel round-trips run in the deferred request. + return view('livewire.dashboard.traffic-analytics-placeholder'); + } + + public function render() + { + return view('livewire.dashboard.traffic-analytics'); + } +} diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index a3605f28eb..2569743878 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -29,9 +29,6 @@ class Docker extends Component #[Validate(['required', 'string'])] public string $serverId; - #[Validate(['required', 'boolean'])] - public bool $isSwarm = false; - public function mount(?string $server_id = null): void { $this->network = new_public_id(); @@ -74,9 +71,10 @@ class Docker extends Component public function submit(): mixed { try { - $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class); + $isSwarm = $this->selectedServer->isSwarm(); + $this->authorize('create', $isSwarm ? SwarmDocker::class : StandaloneDocker::class); $this->validate(); - if ($this->isSwarm) { + if ($isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); if ($found) { throw new \Exception('Network already added to this server.'); diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 03fa2b5109..b0ab4d183e 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -43,7 +43,7 @@ class Show extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -85,7 +85,7 @@ class Show extends Component } $this->destination->delete(); - return redirect()->route('destination.index'); + return redirectRoute($this, 'destination.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index bf64ee8e9d..c6ed818e97 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1507,8 +1507,7 @@ class GlobalSearch extends Component 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', - 'logo' => data_get($service, 'logo'), - ] + array_filter([ + ] + service_logo_urls(data_get($service, 'logo')) + array_filter([ 'amd_only' => data_get($service, 'amd_only') ? true : null, 'arm_only' => data_get($service, 'arm_only') ? true : null, ])); diff --git a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php index decd4fe3aa..4433de0242 100644 --- a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php +++ b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php @@ -8,6 +8,7 @@ trait TogglesNotificationEvents 'deploymentSuccess', 'deploymentFailure', 'statusChange', + 'restartLimitReached', 'backupSuccess', 'backupFailure', 'scheduledTaskSuccess', diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 59ecb06e8e..8e0e149e27 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -34,6 +34,9 @@ class Discord extends Component #[Validate(['boolean'])] public bool $statusChangeDiscordNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedDiscordNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessDiscordNotifications = false; @@ -82,17 +85,17 @@ class Discord extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->discord_enabled = $this->discordEnabled; $this->settings->discord_webhook_url = $this->discordWebhookUrl; $this->settings->deployment_success_discord_notifications = $this->deploymentSuccessDiscordNotifications; $this->settings->deployment_failure_discord_notifications = $this->deploymentFailureDiscordNotifications; $this->settings->status_change_discord_notifications = $this->statusChangeDiscordNotifications; + $this->settings->restart_limit_reached_discord_notifications = $this->restartLimitReachedDiscordNotifications; $this->settings->backup_success_discord_notifications = $this->backupSuccessDiscordNotifications; $this->settings->backup_failure_discord_notifications = $this->backupFailureDiscordNotifications; $this->settings->scheduled_task_success_discord_notifications = $this->scheduledTaskSuccessDiscordNotifications; @@ -107,7 +110,9 @@ class Discord extends Component $this->settings->discord_ping_enabled = $this->discordPingEnabled; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->discordEnabled = $this->settings->discord_enabled; @@ -118,6 +123,7 @@ class Discord extends Component $this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications; $this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications; $this->statusChangeDiscordNotifications = $this->settings->status_change_discord_notifications; + $this->restartLimitReachedDiscordNotifications = $this->settings->restart_limit_reached_discord_notifications; $this->backupSuccessDiscordNotifications = $this->settings->backup_success_discord_notifications; $this->backupFailureDiscordNotifications = $this->settings->backup_failure_discord_notifications; $this->scheduledTaskSuccessDiscordNotifications = $this->settings->scheduled_task_success_discord_notifications; @@ -193,6 +199,7 @@ class Discord extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -203,6 +210,7 @@ class Discord extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -212,6 +220,8 @@ class Discord extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); @@ -232,4 +242,11 @@ class Discord extends Component { return view('livewire.notifications.discord'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.discord.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 88b3587349..70b424d23a 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -79,6 +79,9 @@ class Email extends Component #[Validate(['boolean'])] public bool $statusChangeEmailNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedEmailNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessEmailNotifications = false; @@ -129,12 +132,11 @@ class Email extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); - $this->authorize('update', $this->settings); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -155,6 +157,7 @@ class Email extends Component $this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications; $this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications; $this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications; + $this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications; $this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications; $this->settings->backup_failure_email_notifications = $this->backupFailureEmailNotifications; $this->settings->scheduled_task_success_email_notifications = $this->scheduledTaskSuccessEmailNotifications; @@ -166,7 +169,9 @@ class Email extends Component $this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications; $this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications; $this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); } else { $this->smtpEnabled = $this->settings->smtp_enabled; @@ -193,6 +198,7 @@ class Email extends Component $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; $this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications; + $this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications; $this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications; $this->backupFailureEmailNotifications = $this->settings->backup_failure_email_notifications; $this->scheduledTaskSuccessEmailNotifications = $this->settings->scheduled_task_success_email_notifications; @@ -219,6 +225,8 @@ class Email extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); $this->dispatch('success', 'Email notifications settings updated.'); } @@ -321,7 +329,9 @@ class Email extends Component $this->settings->smtp_timeout = $this->smtpTimeout; $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); $this->dispatch('success', 'SMTP settings updated.'); } catch (\Throwable $e) { $this->smtpEnabled = false; @@ -346,7 +356,9 @@ class Email extends Component $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); $this->dispatch('success', 'Resend settings updated.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -455,4 +467,14 @@ class Email extends Component { return view('livewire.notifications.email'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.email.updated', [ + 'team_id' => $this->team->id, + 'changed_fields' => array_values(array_diff($changedFields, ['smtp_password', 'resend_api_key'])), + ]); + } + } } diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index b1608c5ea2..5e9abd9407 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -41,6 +41,9 @@ class Pushover extends Component #[Validate(['boolean'])] public bool $statusChangePushoverNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedPushoverNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessPushoverNotifications = false; @@ -86,11 +89,10 @@ class Pushover extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->pushover_enabled = $this->pushoverEnabled; $this->settings->pushover_user_key = $this->pushoverUserKey; $this->settings->pushover_api_token = $this->pushoverApiToken; @@ -98,6 +100,7 @@ class Pushover extends Component $this->settings->deployment_success_pushover_notifications = $this->deploymentSuccessPushoverNotifications; $this->settings->deployment_failure_pushover_notifications = $this->deploymentFailurePushoverNotifications; $this->settings->status_change_pushover_notifications = $this->statusChangePushoverNotifications; + $this->settings->restart_limit_reached_pushover_notifications = $this->restartLimitReachedPushoverNotifications; $this->settings->backup_success_pushover_notifications = $this->backupSuccessPushoverNotifications; $this->settings->backup_failure_pushover_notifications = $this->backupFailurePushoverNotifications; $this->settings->scheduled_task_success_pushover_notifications = $this->scheduledTaskSuccessPushoverNotifications; @@ -110,7 +113,9 @@ class Pushover extends Component $this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications; $this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->pushoverEnabled = $this->settings->pushover_enabled; @@ -125,6 +130,7 @@ class Pushover extends Component $this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications; $this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications; $this->statusChangePushoverNotifications = $this->settings->status_change_pushover_notifications; + $this->restartLimitReachedPushoverNotifications = $this->settings->restart_limit_reached_pushover_notifications; $this->backupSuccessPushoverNotifications = $this->settings->backup_success_pushover_notifications; $this->backupFailurePushoverNotifications = $this->settings->backup_failure_pushover_notifications; $this->scheduledTaskSuccessPushoverNotifications = $this->settings->scheduled_task_success_pushover_notifications; @@ -190,6 +196,7 @@ class Pushover extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -202,6 +209,7 @@ class Pushover extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -211,6 +219,8 @@ class Pushover extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); @@ -231,4 +241,11 @@ class Pushover extends Component { return view('livewire.notifications.pushover'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.pushover.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index c4ca7da802..a96b452f82 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -39,6 +39,9 @@ class Slack extends Component #[Validate(['boolean'])] public bool $statusChangeSlackNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedSlackNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessSlackNotifications = false; @@ -84,17 +87,17 @@ class Slack extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->slack_enabled = $this->slackEnabled; $this->settings->slack_webhook_url = $this->slackWebhookUrl; $this->settings->deployment_success_slack_notifications = $this->deploymentSuccessSlackNotifications; $this->settings->deployment_failure_slack_notifications = $this->deploymentFailureSlackNotifications; $this->settings->status_change_slack_notifications = $this->statusChangeSlackNotifications; + $this->settings->restart_limit_reached_slack_notifications = $this->restartLimitReachedSlackNotifications; $this->settings->backup_success_slack_notifications = $this->backupSuccessSlackNotifications; $this->settings->backup_failure_slack_notifications = $this->backupFailureSlackNotifications; $this->settings->scheduled_task_success_slack_notifications = $this->scheduledTaskSuccessSlackNotifications; @@ -107,7 +110,9 @@ class Slack extends Component $this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications; $this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->slackEnabled = $this->settings->slack_enabled; @@ -118,6 +123,7 @@ class Slack extends Component $this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications; $this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications; $this->statusChangeSlackNotifications = $this->settings->status_change_slack_notifications; + $this->restartLimitReachedSlackNotifications = $this->settings->restart_limit_reached_slack_notifications; $this->backupSuccessSlackNotifications = $this->settings->backup_success_slack_notifications; $this->backupFailureSlackNotifications = $this->settings->backup_failure_slack_notifications; $this->scheduledTaskSuccessSlackNotifications = $this->settings->scheduled_task_success_slack_notifications; @@ -179,6 +185,7 @@ class Slack extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -191,6 +198,7 @@ class Slack extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -200,6 +208,8 @@ class Slack extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); @@ -220,4 +230,11 @@ class Slack extends Component { return view('livewire.notifications.slack'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.slack.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 9f19b22f5f..6b362f7869 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -41,6 +41,9 @@ class Telegram extends Component #[Validate(['boolean'])] public bool $statusChangeTelegramNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedTelegramNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessTelegramNotifications = false; @@ -83,6 +86,9 @@ class Telegram extends Component #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsStatusChangeThreadId = null; + #[Validate(['nullable', 'string', 'max:255'])] + public ?string $telegramNotificationsRestartLimitReachedThreadId = null; + #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsBackupSuccessThreadId = null; @@ -128,11 +134,10 @@ class Telegram extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->telegram_enabled = $this->telegramEnabled; $this->settings->telegram_token = $this->telegramToken; $this->settings->telegram_chat_id = $this->telegramChatId; @@ -140,6 +145,7 @@ class Telegram extends Component $this->settings->deployment_success_telegram_notifications = $this->deploymentSuccessTelegramNotifications; $this->settings->deployment_failure_telegram_notifications = $this->deploymentFailureTelegramNotifications; $this->settings->status_change_telegram_notifications = $this->statusChangeTelegramNotifications; + $this->settings->restart_limit_reached_telegram_notifications = $this->restartLimitReachedTelegramNotifications; $this->settings->backup_success_telegram_notifications = $this->backupSuccessTelegramNotifications; $this->settings->backup_failure_telegram_notifications = $this->backupFailureTelegramNotifications; $this->settings->scheduled_task_success_telegram_notifications = $this->scheduledTaskSuccessTelegramNotifications; @@ -155,6 +161,7 @@ class Telegram extends Component $this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId; $this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId; $this->settings->telegram_notifications_status_change_thread_id = $this->telegramNotificationsStatusChangeThreadId; + $this->settings->telegram_notifications_restart_limit_reached_thread_id = $this->telegramNotificationsRestartLimitReachedThreadId; $this->settings->telegram_notifications_backup_success_thread_id = $this->telegramNotificationsBackupSuccessThreadId; $this->settings->telegram_notifications_backup_failure_thread_id = $this->telegramNotificationsBackupFailureThreadId; $this->settings->telegram_notifications_scheduled_task_success_thread_id = $this->telegramNotificationsScheduledTaskSuccessThreadId; @@ -167,12 +174,29 @@ class Telegram extends Component $this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId; $this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); } else { $this->telegramEnabled = $this->settings->telegram_enabled; if (auth()->user()->can('update', $this->settings)) { $this->telegramToken = $this->settings->telegram_token; $this->telegramChatId = $this->settings->telegram_chat_id; + $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; + $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; + $this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id; + $this->telegramNotificationsRestartLimitReachedThreadId = $this->settings->telegram_notifications_restart_limit_reached_thread_id; + $this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id; + $this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id; + $this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id; + $this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id; + $this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id; + $this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id; + $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; + $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; + $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; + $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; + $this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id; } else { $this->telegramToken = null; $this->telegramChatId = null; @@ -181,6 +205,7 @@ class Telegram extends Component $this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications; $this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications; $this->statusChangeTelegramNotifications = $this->settings->status_change_telegram_notifications; + $this->restartLimitReachedTelegramNotifications = $this->settings->restart_limit_reached_telegram_notifications; $this->backupSuccessTelegramNotifications = $this->settings->backup_success_telegram_notifications; $this->backupFailureTelegramNotifications = $this->settings->backup_failure_telegram_notifications; $this->scheduledTaskSuccessTelegramNotifications = $this->settings->scheduled_task_success_telegram_notifications; @@ -193,26 +218,13 @@ class Telegram extends Component $this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications; $this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications; - $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; - $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; - $this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id; - $this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id; - $this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id; - $this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id; - $this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id; - $this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id; - $this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id; - $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; - $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; - $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; - $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; - $this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id; } } public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -225,6 +237,7 @@ class Telegram extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -282,6 +295,8 @@ class Telegram extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); @@ -302,4 +317,11 @@ class Telegram extends Component { return view('livewire.notifications.telegram'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.telegram.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index ee07694767..dfc0fabd30 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -34,6 +34,9 @@ class Webhook extends Component #[Validate(['boolean'])] public bool $statusChangeWebhookNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedWebhookNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessWebhookNotifications = false; @@ -79,17 +82,17 @@ class Webhook extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->webhook_enabled = $this->webhookEnabled; $this->settings->webhook_url = $this->webhookUrl; $this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications; $this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications; $this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications; + $this->settings->restart_limit_reached_webhook_notifications = $this->restartLimitReachedWebhookNotifications; $this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications; $this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications; $this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications; @@ -102,7 +105,9 @@ class Webhook extends Component $this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications; $this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->webhookEnabled = $this->settings->webhook_enabled; @@ -113,6 +118,7 @@ class Webhook extends Component $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; $this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications; + $this->restartLimitReachedWebhookNotifications = $this->settings->restart_limit_reached_webhook_notifications; $this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications; $this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications; $this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications; @@ -171,6 +177,7 @@ class Webhook extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -181,6 +188,7 @@ class Webhook extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -190,6 +198,8 @@ class Webhook extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); @@ -212,4 +222,11 @@ class Webhook extends Component { return view('livewire.notifications.webhook'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.webhook.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index ae5d9b3ecd..9b1ef42ce2 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -47,7 +47,7 @@ class Index extends Component $avatarStorage->store(Auth::user(), $this->avatar); $this->reset('avatar'); - $this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp])); + $this->dispatch('avatar-updated', url: profile_avatar_url(Auth::user()->fresh())); $this->dispatch('success', 'Profile picture updated.'); return true; @@ -95,6 +95,7 @@ class Index extends Component Auth::user()->update([ 'name' => $this->name, ]); + auditLog('ui.user.profile_updated', $this->auditContext(['changed_fields' => ['name']])); $this->dispatch('success', 'Profile updated.'); } catch (\Throwable $e) { @@ -154,6 +155,7 @@ class Index extends Component } Auth::user()->requestEmailChange($this->new_email); + auditLog('ui.user.email_change_requested', $this->auditContext()); $this->show_email_change = false; $this->show_verification = true; @@ -216,6 +218,7 @@ class Index extends Component $this->show_verification = false; $this->dispatch('success', 'Email address updated successfully.'); + auditLog('ui.user.email_changed', $this->auditContext()); } else { $this->dispatch('error', 'Failed to update email address.'); } @@ -328,6 +331,7 @@ class Index extends Component auth()->user()->update([ 'password' => Hash::make($this->new_password), ]); + auditLog('ui.user.password_changed', $this->auditContext()); $this->dispatch('success', 'Password updated.'); $this->current_password = ''; $this->new_password = ''; @@ -346,6 +350,17 @@ class Index extends Component }; } + private function auditContext(array $context = []): array + { + $user = Auth::user(); + + return array_merge([ + 'team_id' => $user->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user->name, + ], $context); + } + public function render() { return view('livewire.profile.index'); diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index bf84f385dd..7161358ed0 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use App\Models\ApplicationSetting; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -27,12 +29,6 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitShallowCloneEnabled = false; - #[Validate(['boolean'])] - public bool $isPreviewDeploymentsEnabled = false; - - #[Validate(['boolean'])] - public bool $isPrDeploymentsPublicEnabled = false; - #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -72,9 +68,12 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isConsistentContainerNameEnabled = false; - #[Validate(['string', 'nullable'])] + #[Validate(['nullable', 'string', 'max:255', 'regex:'.ValidationPatterns::CONTAINER_NAME_PATTERN])] public ?string $customInternalName = null; + #[Validate(['string', 'nullable', 'max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH])] + public ?string $customContainerNamePrefix = null; + #[Validate(['boolean'])] public bool $isGzipEnabled = true; @@ -88,7 +87,7 @@ class Advanced extends Component public bool $isConnectToDockerNetworkEnabled = false; #[Validate(['integer', 'min:0'])] - public int $maxRestartCount = 10; + public int $maxRestartCount = 0; public function mount() { @@ -99,7 +98,7 @@ class Advanced extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -107,8 +106,6 @@ class Advanced extends Component $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; - $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; - $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -119,6 +116,7 @@ class Advanced extends Component $this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled; $this->application->settings->is_consistent_container_name_enabled = $this->isConsistentContainerNameEnabled; $this->application->settings->custom_internal_name = $this->customInternalName; + $this->application->settings->custom_container_name_prefix = $this->customContainerNamePrefix; $this->application->settings->is_gzip_enabled = $this->isGzipEnabled; $this->application->settings->is_stripprefix_enabled = $this->isStripprefixEnabled; $this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled; @@ -126,7 +124,9 @@ class Advanced extends Component $this->application->settings->disable_build_cache = $this->disableBuildCache; $this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile; $this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild; + $changedFields = array_keys($this->application->settings->getDirty()); $this->application->settings->save(); + $this->auditSettingsUpdate($changedFields); } else { $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $this->isGzipEnabled = $this->application->isGzipEnabled(); @@ -136,8 +136,6 @@ class Advanced extends Component $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; - $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; - $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; @@ -147,12 +145,13 @@ class Advanced extends Component $this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled; $this->isConsistentContainerNameEnabled = $this->application->settings->is_consistent_container_name_enabled; $this->customInternalName = $this->application->settings->custom_internal_name; + $this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix; $this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled; $this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network; $this->disableBuildCache = $this->application->settings->disable_build_cache; $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; - $this->maxRestartCount = $this->application->max_restart_count ?? 10; + $this->maxRestartCount = $this->application->max_restart_count ?? 0; } // Load stop_grace_period separately since it has its own save handler @@ -268,6 +267,28 @@ class Advanced extends Component } } + public function saveCustomNamePrefix() + { + try { + $this->authorize('update', $this->application); + + $this->customContainerNamePrefix = str($this->customContainerNamePrefix)->slug()->value() ?: null; + + if ($this->customContainerNamePrefix && ApplicationSetting::isContainerNamePrefixInUse($this->customContainerNamePrefix, $this->application->destination->server, $this->application->id)) { + $this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix; + $this->dispatch('error', 'This container name prefix is already in use by another application on this Coolify instance.'); + + return; + } + + $this->syncData(true); + $this->dispatch('success', 'Container name prefix saved.'); + $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function saveStopGracePeriod() { try { @@ -283,7 +304,9 @@ class Advanced extends Component $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null ? null : (int) $validated['stopGracePeriod']; + $changedFields = array_keys($this->application->settings->getDirty()); $this->application->settings->save(); + $this->auditSettingsUpdate($changedFields); $this->dispatch('success', 'Stop grace period updated.'); $this->dispatch('configurationChanged'); @@ -313,4 +336,20 @@ class Advanced extends Component { return view('livewire.project.application.advanced'); } + + /** @param array $changedFields */ + private function auditSettingsUpdate(array $changedFields): void + { + $changedFields = array_values(array_diff($changedFields, ['updated_at'])); + if ($changedFields === []) { + return; + } + + auditLog('ui.application.settings_updated', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + 'changed_fields' => $changedFields, + ]); + } } diff --git a/app/Livewire/Project/Application/Analytics.php b/app/Livewire/Project/Application/Analytics.php new file mode 100644 index 0000000000..52d15d9e10 --- /dev/null +++ b/app/Livewire/Project/Application/Analytics.php @@ -0,0 +1,246 @@ +>> */ + public array $breakdowns = []; + + public ?string $attribution = null; + + /** + * Per-bucket status-class time series for the stacked area chart. Empty when this + * app's Sentinel lacks the series endpoint, which flips the chart to the donut. + * + * @var array + */ + public array $series = []; + + public bool $hasSeries = false; + + /** @var array */ + protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; + + public function mount(): void + { + $this->enabled = (bool) $this->application->destination?->server?->isTrafficAnalyticsEnabled(); + + if ($this->enabled) { + $this->loadData(); + } + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + public function toggleLive(): void + { + if ($this->range !== '24h') { + return; + } + $this->live = ! $this->live; + } + + /** + * Realtime polling is only armed when the user has it on and the range is 24h. + */ + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + + public function loadData(): void + { + if (! $this->enabled) { + return; + } + + try { + [$from, $to] = $this->window(); + $client = $this->trafficClient(); + $key = $this->application->uuid; + + // Warm every endpoint for this app in one docker exec instead of ~14 serial + // SSH round-trips; the per-call methods below then read from cache. + $client->prefetchServerWide($key, $from, $to, $this->breakdownDimensions, $this->range); + + $this->overview = $client->overview($key, $from, $to)->toArray(); + + // Every path belongs to this one app, so decorate each row with its domain + // for a consistent "domain + path" presentation and an openable live link. + $domain = $this->applicationDomain(); + $this->topPaths = $client->paths($key, $from, $to, 50) + ->map(fn ($path) => ['domain' => $domain] + $path->toArray()) + ->all(); + + $breakdowns = []; + foreach ($this->breakdownDimensions as $dimension) { + $rows = $client->breakdown($key, $dimension, $from, $to, 50) + ->map(fn ($row) => $row->toArray()) + ->all(); + $breakdowns[$dimension] = $dimension === 'referer' + ? groupRefererBreakdownRows($rows) + : $rows; + } + $this->breakdowns = $breakdowns; + + $this->attribution = $client->attribution(); + + // Per-bucket status series; absent on older Sentinel builds (empty → donut fallback). + // Isolated so a series hiccup never errors the rest of the widget. + try { + $this->series = $client->series($key, $this->range) + ->map(fn ($bucket) => $bucket->toArray()) + ->all(); + } catch (\Throwable $e) { + $this->series = []; + } + $this->hasSeries = $this->series !== []; + + $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Payload for the status chart: the stacked-area time series when available, + * plus the donut totals as a fallback for older Sentinel builds. + * + * @return array + */ + protected function chartPayload(): array + { + $device = $this->deviceChartData(); + + return [ + 'hasSeries' => $this->hasSeries, + 'range' => $this->range, + 'seriesData' => [ + $this->overview['s2xx'] ?? 0, + $this->overview['s3xx'] ?? 0, + $this->overview['s4xx'] ?? 0, + $this->overview['s5xx'] ?? 0, + ], + 'timeSeries' => [ + 'categories' => array_column($this->series, 'bucket'), + 'requests' => $this->requestsSpark(), + 's2xx' => array_column($this->series, 's2xx'), + 's3xx' => array_column($this->series, 's3xx'), + 's4xx' => array_column($this->series, 's4xx'), + 's5xx' => array_column($this->series, 's5xx'), + ], + 'requestsSpark' => $this->requestsSpark(), + 'sparkCategories' => array_column($this->series, 'bucket'), + 'errorsSpark' => $this->errorsSpark(), + 'bandwidthSpark' => $this->bandwidthSpark(), + 'uniquesSpark' => $this->uniquesSpark(), + 'latencySpark' => $this->latencySpark(), + 'geo' => $this->geoMarkers(), + 'deviceLabels' => $device['labels'], + 'deviceSeries' => $device['series'], + ]; + } + + public function errorRate(): float + { + if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { + return 0.0; + } + + $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); + + return round(($errors / $this->overview['requests']) * 100, 2); + } + + public function bandwidthBytes(): int + { + if (! $this->overview) { + return 0; + } + + return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0); + } + + protected function trafficClient(): SentinelTrafficClient + { + return app(SentinelTrafficClient::class, ['server' => $this->application->destination->server]); + } + + /** + * Primary domain host for this application (first configured FQDN), or null when + * none is set — used to present paths as "domain + path" with an openable link. + */ + protected function applicationDomain(): ?string + { + $first = collect($this->application->fqdns)->first(); + + return $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null; + } + + /** + * @return array{0: string, 1: string} + */ + private function window(): array + { + $to = now(); + $from = match ($this->range) { + '7d' => now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; + } + + public function placeholder(array $params = []): View + { + $application = $params['application'] ?? null; + + if ($application instanceof Application && ! $application->destination?->server?->isTrafficAnalyticsEnabled()) { + $this->application = $application; + $this->enabled = false; + + return view('livewire.project.application.analytics'); + } + + // Rendered instantly; the Sentinel round-trip runs in the deferred lazy-load request. + return view('livewire.project.application.analytics-placeholder'); + } + + public function render() + { + return view('livewire.project.application.analytics'); + } +} diff --git a/app/Livewire/Project/Application/Deployment/Index.php b/app/Livewire/Project/Application/Deployment/Index.php index fb24414cdd..e94f37544f 100644 --- a/app/Livewire/Project/Application/Deployment/Index.php +++ b/app/Livewire/Project/Application/Deployment/Index.php @@ -261,13 +261,15 @@ class Index extends Component ->where('application_id', $this->application->id) ->where('pull_request_id', '>', 0) ->distinct() - ->orderByDesc('pull_request_id') ->pluck('pull_request_id') + ->merge($this->application->previews()->pluck('pull_request_id')) ->map(fn ($pullRequestId) => (string) $pullRequestId) + ->unique() + ->sortByDesc(fn (string $pullRequestId) => (int) $pullRequestId) ->values(); if ($this->pull_request_id && ! $pullRequestIds->contains($this->pull_request_id)) { - $this->pull_request_id = null; + $pullRequestIds->prepend($this->pull_request_id); } $this->pullRequestOptions = collect([ diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 2f9370871c..929c02e93e 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -5,11 +5,14 @@ namespace App\Livewire\Project\Application; use App\Actions\Shared\CheckDomainDns; use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; +use App\Livewire\Concerns\InteractsWithDnsProviders; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; use App\Models\Server; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -19,6 +22,7 @@ class Domains extends Component { use AuthorizesRequests; use InteractsWithCloudflareDomainConnect; + use InteractsWithDnsProviders; protected bool $notifyRedirectUpdate = true; @@ -57,7 +61,17 @@ class Domains extends Component public ?string $editingService = null; - /** @var array */ + public string $editingIndexing = 'index'; + + public string $editingRedirect = 'both'; + + public string $editingOriginalRedirect = 'both'; + + public bool $editingDomainWasRegenerated = false; + + public ?string $editingGeneratedHost = null; + + /** @var array */ public array $domainRows = []; /** When set, the next addSuggestedDomain call for this index skips the DNS block. */ @@ -70,6 +84,14 @@ class Domains extends Component public bool $showDomainConflictModal = false; + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + public bool $forceSaveDomains = false; public bool $forceSaveDns = false; @@ -108,11 +130,20 @@ class Domains extends Component 'confirmDomainUsage', ]; + public function getListeners(): array + { + return array_merge($this->listeners, [ + 'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished', + ]); + } + protected function rules(): array { return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), + 'editingIndexing' => 'string|required|in:index,noindex', + 'editingRedirect' => 'string|required|in:both,www,non-www', 'redirect' => 'string|required|in:both,www,non-www', 'isForceHttpsEnabled' => 'boolean', 'serviceRedirects' => 'array', @@ -141,7 +172,15 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && ($row['service'] ?? null) === ($editingRow['service'] ?? null)); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -218,7 +257,9 @@ class Domains extends Component $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; - $this->redirect = $this->application->redirect ?? 'both'; + if ($this->pendingAction !== 'redirect' || $this->isCompose) { + $this->redirect = $this->application->redirect ?? 'both'; + } $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); @@ -245,6 +286,9 @@ class Domains extends Component } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -281,7 +325,9 @@ class Domains extends Component $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( - is_string($storedRedirect) ? $storedRedirect : null + $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService + ? $pendingRedirect + : (is_string($storedRedirect) ? $storedRedirect : null) ); } } @@ -485,32 +531,19 @@ class Domains extends Component /** * @param array $stored - * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool} + * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool} */ protected function domainRowFromStored(string $url, ?string $service, array $stored): array { $key = $this->domainDnsStatusKey($url, $service); $entry = $stored[$key] ?? null; + $port = $this->effectiveDomainInternalPort($url, $service); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'url' => $url, - 'service' => $service, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'check_id' => data_get($entry, 'check_id'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'url' => $url, 'service' => $service, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, @@ -521,6 +554,119 @@ class Domains extends Component 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url, ?string $service = null): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->application->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + $composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + + if ($this->isCompose && $service !== null) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + + if ($this->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $exposed = $this->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + protected function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + protected function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->application->portRequiresConfirmation($port, $serviceName); + } + + protected function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; } /** @@ -559,45 +705,17 @@ class Domains extends Component $this->authorize('update', $this->application); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->application->destination?->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->application); - $this->isCheckingDns = true; - - try { - $server = $this->application->destination?->server; - $skipDns = ! $this->dnsValidationEnabled - || ! $server - || $this->application->additional_servers->count() > 0; - - $indexesToCheck = []; - - foreach ($this->domainRows as $index => $row) { - if ($skipDns) { - $reason = ! $this->dnsValidationEnabled - ? 'DNS validation is disabled in instance settings.' - : ($this->application->additional_servers->count() > 0 - ? 'DNS check skipped for multi-server applications.' - : 'No server available for DNS validation.'); - - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = $reason; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - - continue; - } - - $indexesToCheck[] = $index; - } - - if ($server && $indexesToCheck !== []) { - $this->applyDnsStatuses($indexesToCheck, $server); - } - - $this->persistDomainDnsStatuses(); - } finally { - $this->isCheckingDns = false; + foreach ($this->domainRows as $row) { + $this->queueUrlsDns([$row['url']], $row['service'] ?? null); } } @@ -609,18 +727,8 @@ class Domains extends Component return; } - $server = $this->application->destination?->server; - if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) { - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = 'DNS check skipped.'; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->persistDomainDnsStatuses(); - - return; - } - - $this->applyDnsStatus($index, $server); - $this->persistDomainDnsStatuses(); + $row = $this->domainRows[$index]; + $this->queueUrlsDns([$row['url']], $row['service'] ?? null); } protected function applyDnsStatus(int $index, Server $server): void @@ -824,6 +932,31 @@ class Domains extends Component $this->addDomain(); } + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + /** * Clear pending conflict state when the modal is dismissed without confirmation. * confirmDomainUsage sets forceSaveDomains before closing the modal. @@ -834,7 +967,13 @@ class Domains extends Component return; } + $this->authorize('update', $this->application); + $wasRedirect = $this->pendingAction === 'redirect'; $this->pendingAction = null; + $this->pendingRedirectService = null; + if ($wasRedirect) { + $this->refreshDomains(); + } } public function addDomain(): void @@ -848,7 +987,7 @@ class Domains extends Component return; } - if ($this->newDomainPartsChanged) { + if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) { $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); } $this->validateOnly('newDomain'); @@ -867,15 +1006,24 @@ class Domains extends Component ->values() ->all(); $current = $this->currentDomainList($this->newDomainService); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured."); return; } } + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); + + return; + } + $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; if (! $this->saveDomainList($merged, $this->newDomainService)) { @@ -884,12 +1032,19 @@ class Domains extends Component $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); $this->refreshDomains(); - $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); - $dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck)) + $addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + if ($this->configureDnsAfterDomainAdd($addedUrls)) { + $this->dispatch('success', 'Domain added.'); + + return; + } + + $dnsChecks = collect($this->dnsEntriesForUrls($addedUrls, $serviceForCheck)) ->map(fn (string $url, string $statusKey) => [ 'status_key' => $statusKey, 'url' => $url, @@ -1007,6 +1162,7 @@ class Domains extends Component $skipDns = ! $this->dnsValidationEnabled || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; @@ -1043,6 +1199,34 @@ class Domains extends Component $this->persistDomainDnsStatuses(); } + /** + * @param array $urls + */ + protected function queueUrlsDns(array $urls, ?string $service = null): void + { + foreach ($this->dnsEntriesForUrls($urls, $service) as $statusKey => $url) { + $checkId = new_public_id(); + $this->markUrlsAsChecking([$url], $service, $checkId); + $this->persistDomainDnsStatuses(); + + try { + CheckDomainDnsJob::dispatch( + $this->application, + $statusKey, + $url, + $this->application->destination?->server, + $this->serverIp, + $checkId, + $this->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $this->markUrlsDnsCheckUnavailable([$url], $service, $checkId); + $this->persistDomainDnsStatuses(); + $this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.'); + } + } + } + protected function shouldValidateDnsForAdd(): bool { if (! $this->dnsValidationEnabled) { @@ -1109,8 +1293,18 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $canonical = DomainPortOverrides::withoutPort($this->editingDomain); + $savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; + $this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index'; + $this->editingRedirect = $this->serviceRedirectFor($this->editingService); + $this->editingOriginalRedirect = $this->editingRedirect; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; @@ -1196,6 +1390,11 @@ class Domains extends Component $this->editingDomainParts = DomainUrlParts::empty(); $this->editingDomainPartsChanged = false; $this->editingService = null; + $this->editingIndexing = 'index'; + $this->editingRedirect = 'both'; + $this->editingOriginalRedirect = 'both'; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); if ($this->pendingAction === 'update') { @@ -1211,6 +1410,39 @@ class Domains extends Component $this->updateDomain(); } + public function regenerateEditingDomain(): void + { + $this->authorize('update', $this->application); + + if ($this->labelsAreWritable || $this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + + $server = data_get($this->application, 'destination.server'); + if (! $server) { + $this->dispatch('error', 'No server found for this application.'); + + return; + } + + $generatedHost = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST); + if (! is_string($generatedHost) || $generatedHost === '') { + $this->dispatch('error', 'Could not generate a domain.'); + + return; + } + + $currentHost = (string) ($this->editingDomainParts['host'] ?? ''); + $this->editingGeneratedHost = $generatedHost; + $this->editingDomainParts['host'] = str_starts_with(strtolower($currentHost), 'www.') + ? 'www.'.$generatedHost + : $generatedHost; + $this->editingDomainPartsChanged = true; + $this->editingDomainWasRegenerated = true; + $this->resetEditDomainDnsGate(); + $this->resetErrorBag('editingDomain'); + } + public function updateDomain(): void { try { @@ -1226,10 +1458,12 @@ class Domains extends Component return; } - if ($this->editingDomainPartsChanged) { + if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); + $this->validateOnly('editingIndexing'); + $this->validateOnly('editingRedirect'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) { @@ -1241,53 +1475,110 @@ class Domains extends Component $newUrl = $this->splitDomains($normalized)[0]; $oldUrl = $this->domainRows[$this->editingIndex]['url']; $service = $this->editingService; - $wasNoindexed = $this->application->isDomainNoindexed($oldUrl); + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $this->application->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $this->application->domain_port_overrides = $portOverrides ?: null; + } $current = $this->currentDomainList($service); - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured."); return; } - if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) { - $dnsFailure = $this->findDnsFailureMessage([$newUrl]); - if ($dnsFailure !== null) { - $this->editDomainDnsFailed = true; - $this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure); - $this->showEditDomainModal = true; + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); - return; - } - } - - $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); - $this->pendingAction = 'update'; - if (! $this->saveDomainList($updated, $service)) { return; } - $noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); - if ($wasNoindexed) { - $noindexDomains->push($newUrl); + $replacements = [$oldUrl => $newUrl]; + if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + $oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST); + $oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost); + if (is_string($oldCounterpart)) { + $counterpartParts = DomainUrlParts::split($oldCounterpart); + $counterpartPort = $this->currentRowPort($oldCounterpart); + if ($counterpartPort !== null) { + $counterpartParts['port'] = (string) $counterpartPort; + } + $counterpartParts['host'] = str_starts_with(strtolower($counterpartParts['host']), 'www.') + ? 'www.'.$this->editingGeneratedHost + : $this->editingGeneratedHost; + $replacements[$oldCounterpart] = DomainUrlParts::compose(...$counterpartParts); + } } - $this->application->setNoindexDomains($noindexDomains); - $this->application->save(); + + $updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values(); + if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + foreach ($updated->all() as $url) { + $counterpart = $this->wwwCounterpartUrl($url, true); + $counterpartHost = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null; + $hasCounterpart = filled($counterpartHost) && $updated->contains( + fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $counterpartHost + ); + if (filled($counterpart) && ! $hasCounterpart) { + $updated->push($counterpart); + } + } + } + $urlsToCheck = $updated + ->reject(fn (string $url): bool => $current->contains( + fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url) + )) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->unique() + ->values() + ->all(); + + $noindexDomains = $this->application->noindexDomains(); + foreach ($replacements as $previousUrl => $replacementUrl) { + $wasNoindexed = $previousUrl === $oldUrl + ? $this->editingIndexing === 'noindex' + : $this->application->isDomainNoindexed($previousUrl); + $noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl); + if ($wasNoindexed) { + $noindexDomains->push($replacementUrl); + } + } + if ($this->isCompose) { + $allDomains = json_decode($this->application->docker_compose_domains ?: '[]', true); + $existing = is_array($allDomains[$service] ?? null) ? $allDomains[$service] : []; + $allDomains[$service] = array_merge($existing, ['redirect' => $this->editingRedirect]); + $this->application->docker_compose_domains = json_encode($allDomains); + } else { + $this->application->redirect = $this->editingRedirect; + } + + $this->pendingAction = 'update'; + if (! $this->saveDomainList($updated, $service, noindexDomains: $noindexDomains)) { + return; + } + $this->resetDefaultLabels(); $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->dispatch('success', 'Domain updated.'); $this->refreshDomains(); - $this->checkUrlsDns([$newUrl], $service); + if ($urlsToCheck !== []) { + $this->queueUrlsDns($urlsToCheck, $service); + } } catch (\Throwable $e) { handleError($e, $this); } } - public function removeDomain(int $index): void + public function removeDomain(int $index, string $password = '', array $selectedActions = []): void { try { $this->authorize('update', $this->application); @@ -1310,6 +1601,10 @@ class Domains extends Component return; } + if (in_array('deleteManagedDns', $selectedActions, true)) { + $this->deleteManagedDnsForUrl($url); + } + if ($this->editingIndex === $index) { $this->cancelEdit(); } @@ -1322,6 +1617,33 @@ class Domains extends Component } } + public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => ! ($row['is_suggested'] ?? false) + && hash_equals($domainKey, $this->domainRowKey($row)) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index, $password, $selectedActions); + } + + /** + * @param array{url: string, service?: ?string} $row + */ + private function domainRowKey(array $row): string + { + return hash('sha256', $row['url'].'|'.($row['service'] ?? '')); + } + + protected function dnsResourceForHostname(string $hostname): ?Model + { + return $this->application; + } + public function generateDomain(?string $serviceName = null): void { try { @@ -1425,7 +1747,7 @@ class Domains extends Component $this->resetDefaultLabels(); $this->dispatch('success', 'Redirect updated.'); $this->refreshDomains(); - $this->checkUrlsDns($addedDomains); + $this->queueUrlsDns($addedDomains); $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); @@ -1522,7 +1844,7 @@ class Domains extends Component $this->dispatch('success', "Redirect updated for {$serviceName}."); } $this->refreshDomains(); - $this->checkUrlsDns($addedDomains, $serviceName); + $this->queueUrlsDns($addedDomains, $serviceName); $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); @@ -1787,6 +2109,7 @@ class Domains extends Component Collection $domains, ?string $serviceName = null, bool $checkConflicts = true, + ?Collection $noindexDomains = null, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1800,6 +2123,8 @@ class Domains extends Component } } + $intendedComposeOverrides = null; + if ($this->isCompose) { if (blank($serviceName)) { $this->dispatch('error', 'A service is required for compose domains.'); @@ -1815,6 +2140,15 @@ class Domains extends Component $allDomains = []; } + $previousServiceUrls = $this->currentDomainList($serviceName); + $normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides); + $domainString = $normalizedPorts['fqdn']; + $intendedComposeOverrides = $this->mergeComposeDomainPortOverrides( + $previousServiceUrls, + $domainString, + $normalizedPorts['overrides'] ?? null, + ); + $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; // Preserve stored redirect only — pending Direction dropdown values must not // persist until setServiceRedirect() runs. @@ -1823,11 +2157,16 @@ class Domains extends Component ]); $this->application->docker_compose_domains = json_encode($allDomains); + $this->application->domain_port_overrides = $intendedComposeOverrides; $this->application->fqdn = null; } else { $this->application->fqdn = $domainString; } + if ($noindexDomains !== null) { + $this->application->setNoindexDomains($noindexDomains); + } + if ($checkConflicts && ! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); if ($result['hasConflicts']) { @@ -1849,12 +2188,47 @@ class Domains extends Component } $this->application->save(); + + if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) { + $this->application->domain_port_overrides = $intendedComposeOverrides; + $this->application->save(); + } + $this->resetDefaultLabels(); $this->dispatch('configurationChanged'); return true; } + /** + * @param Collection $previousServiceUrls + * @param array|null $incomingOverrides + * @return array|null + */ + protected function mergeComposeDomainPortOverrides( + Collection $previousServiceUrls, + ?string $newDomainString, + ?array $incomingOverrides, + ): ?array { + $merged = $this->application->domain_port_overrides ?? []; + $newCanonical = collect($this->splitDomains($newDomainString)) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->all(); + + foreach ($previousServiceUrls as $url) { + $canonical = DomainPortOverrides::withoutPort($url); + if (! in_array($canonical, $newCanonical, true)) { + unset($merged[$canonical]); + } + } + + foreach ($incomingOverrides ?? [] as $url => $port) { + $merged[$url] = (int) $port; + } + + return $merged ?: null; + } + protected function resetDefaultLabels(): void { try { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 34283cd47f..54562407aa 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\GenerateConfig; use App\Jobs\ApplicationDeploymentJob; +use App\Livewire\Project\Service\Storage; use App\Models\Application; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; @@ -144,7 +145,9 @@ class General extends Component return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'fqdn' => ValidationPatterns::applicationDomainRules(), + 'fqdn' => isset($this->application) && $this->fqdn === $this->application->fqdn + ? ['nullable'] + : ValidationPatterns::applicationDomainRules(), 'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(), 'gitRepository' => 'required', 'gitBranch' => ['required', 'string', new ValidGitBranch], @@ -320,17 +323,6 @@ class General extends Component } } $this->initialDockerComposeLocation = $this->application->docker_compose_location; - if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) { - // Only load compose file if user has update permission - try { - $this->authorize('update', $this->application); - $this->initLoadingCompose = true; - $this->dispatch('info', 'Loading docker compose file.'); - } catch (AuthorizationException $e) { - // User doesn't have update permission, skip loading compose file - } - } - if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { $this->dispatch('configurationChanged'); } @@ -340,7 +332,7 @@ class General extends Component $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -495,6 +487,9 @@ class General extends Component if ($this->isContainerLabelReadonlyEnabled) { $this->resetDefaultLabels(false); } + if ($oldPortsExposes !== $this->portsExposes) { + $this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class); + } $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); @@ -530,7 +525,7 @@ class General extends Component $showToast && $this->dispatch('success', 'Docker compose file loaded.'); $this->dispatch('compose_loaded'); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); $this->dispatch('refreshEnvs'); } catch (\Throwable $e) { // Refresh model to get restored values from Application::loadComposeFile @@ -607,14 +602,9 @@ class General extends Component $this->resetDefaultLabels(false); } if ($this->buildPack === 'dockercompose') { - // Only update if user has permission - try { - $this->authorize('update', $this->application); - $this->fqdn = null; - $this->application->fqdn = null; - $this->application->settings->save(); - } catch (AuthorizationException $e) { - // User doesn't have update permission, just continue without saving + if (blank($this->dockerComposeLocation)) { + $this->dockerComposeLocation = '/docker-compose.yaml'; + $this->application->docker_compose_location = $this->dockerComposeLocation; } } if ($this->buildPack === 'static') { @@ -666,6 +656,8 @@ class General extends Component public function resetDefaultLabels($manualReset = false) { + $this->authorize('update', $this->application); + try { if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) { return; @@ -770,8 +762,11 @@ class General extends Component $oldDockerComposeLocation = $this->initialDockerComposeLocation; $oldBaseDirectory = $this->application->base_directory; - // Process FQDN with intermediate variable to avoid Collection/string confusion - $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); + $fqdnChanged = $this->fqdn !== $this->application->fqdn; + if ($fqdnChanged) { + $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); + } + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); @@ -893,6 +888,9 @@ class General extends Component $this->application->save(); $this->application->refresh(); $this->syncData(); + if ($oldPortsExposes !== $this->portsExposes) { + $this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class); + } $showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!'); } catch (\Throwable $e) { $this->application->refresh(); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 830a4eace8..e52d7c32ec 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\StopApplication; use App\Actions\Docker\GetContainersStatus; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -79,6 +80,19 @@ class Heading extends Component $this->checkStatus(); } + /** + * Log-page URL of the deployment currently running for this application, so a + * "Deploying… View log" indicator can link back to it after the user navigates + * away. Re-evaluated on the heading's 10s poll. Null when nothing is running. + */ + public function getRunningDeploymentUrlProperty(): ?string + { + return ApplicationDeploymentQueue::where('application_id', $this->application->id) + ->whereIn('status', ['in_progress', 'queued']) + ->orderByDesc('id') + ->value('deployment_url'); + } + public function force_deploy_without_cache() { try { diff --git a/app/Livewire/Project/Application/InternalAccess.php b/app/Livewire/Project/Application/InternalAccess.php index 827cafd250..9fc1db43a8 100644 --- a/app/Livewire/Project/Application/InternalAccess.php +++ b/app/Livewire/Project/Application/InternalAccess.php @@ -8,12 +8,21 @@ use Livewire\Component; class InternalAccess extends Component { + protected $listeners = [ + 'applicationNetworkingUpdated' => 'refreshApplicationNetworking', + ]; + public Application $application; public ?string $currentInternalHostname = null; public bool $currentInternalHostnameLoaded = false; + public function refreshApplicationNetworking(): void + { + $this->application->refresh(); + } + public function loadCurrentInternalHostname(): void { try { diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php new file mode 100644 index 0000000000..5f454d91ba --- /dev/null +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -0,0 +1,744 @@ + 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?string $newDomainService = null; + + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?int $editingIndex = null; + + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + + public function mount(): void + { + $this->authorize('view', $this->preview->application); + $this->refreshDomains(); + if ($this->preview->application->build_pack === 'dockercompose') { + $this->newDomainService = $this->composeServices()[0] ?? null; + } + } + + public function render() + { + return view('livewire.project.application.preview-domains', [ + 'isCompose' => $this->preview->application->build_pack === 'dockercompose', + 'composeServices' => $this->composeServices(), + ]); + } + + public function addDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->preview->application->build_pack === 'dockercompose' + && ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) { + $this->addError('newDomainService', 'Select a valid Compose service.'); + + return; + } + $domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host'); + if ($domain === null) { + return; + } + $canonicalDomain = DomainPortOverrides::withoutPort($domain); + if (collect($this->domainRows)->contains( + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain + && $row['service'] === $this->newDomainService + )) { + $this->addError('newDomainParts.host', 'This domain is already configured.'); + + return; + } + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); + + return; + } + $this->domainRows[] = $this->makeRow($domain, $this->newDomainService); + $index = array_key_last($this->domainRows); + $checkId = new_public_id(); + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + if (! $this->persistDomains()) { + return; + } + $domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain); + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainService = $this->preview->application->build_pack === 'dockercompose' + ? ($this->composeServices()[0] ?? null) + : null; + $this->forceUseUnknownPort = false; + $this->dispatch('close-preview-domain-add', previewId: $this->preview->id); + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($domain, $this->domainRows[$index]['service']), + $domain, + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + $this->dispatch('success', 'Domain added. DNS check started.'); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + $this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.'); + } + } + + public function generateDomain(): void + { + $this->authorize('update', $this->preview->application); + $this->preview->refresh(); + if ($this->preview->application->build_pack === 'dockercompose') { + if ($this->newDomainService === null && $this->domainRows === []) { + $this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true); + } else { + $service = $this->newDomainService ?? data_get($this->domainRows, '0.service'); + foreach ($this->generateComposeDomains((string) $service) as $domain) { + $alreadyExists = collect($this->domainRows)->contains( + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain) + && $row['service'] === $service + ); + if (! $alreadyExists) { + $this->domainRows[] = $this->makeRow($domain, $service); + } + } + + if (! $this->persistDomains()) { + return; + } + } + } else { + $this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true); + } + $this->refreshDomains(); + $this->dispatch('success', 'Domain generated.'); + } + + public function startEdit(int $index): void + { + $this->authorize('update', $this->preview->application); + if (! isset($this->domainRows[$index])) { + return; + } + $this->editingIndex = $index; + $this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']); + $canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']); + $savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } + $this->resetErrorBag('editingDomainParts.host'); + $this->dispatch('open-preview-domain-edit', previewId: $this->preview->id); + } + + public function updateDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + $domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host'); + if ($domain === null) { + return; + } + $oldUrl = $this->domainRows[$this->editingIndex]['url']; + $dnsRelevantChange = DomainUrlParts::hasDnsRelevantChange($oldUrl, $domain); + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); + + return; + } + if (blank(DomainUrlParts::split($domain)['port'] ?? null)) { + $portOverrides = $this->preview->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($domain)]); + $this->preview->domain_port_overrides = $portOverrides ?: null; + } + $this->domainRows[$this->editingIndex]['url'] = $domain; + $checkId = $dnsRelevantChange ? new_public_id() : null; + if ($dnsRelevantChange) { + $this->domainRows[$this->editingIndex]['dns_status'] = 'checking'; + $this->domainRows[$this->editingIndex]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$this->editingIndex]['check_id'] = $checkId; + } + $index = $this->editingIndex; + $this->editingIndex = null; + if (! $this->persistDomains()) { + return; + } + $domain = $this->domainRows[$index]['url']; + $this->forceUseUnknownPort = false; + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); + + if (! $dnsRelevantChange) { + $this->dispatch('success', 'Domain updated.'); + + return; + } + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($domain, $this->domainRows[$index]['service']), + $domain, + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + $this->dispatch('success', 'Domain updated. DNS check started.'); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + $this->dispatch('error', 'Domain updated, but the DNS check could not be started. Try again from the preview domains list.'); + } + } + + public function regenerateEditingDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + + $server = $this->preview->application->destination?->server; + if (! $server) { + $this->dispatch('error', 'No server found for this preview.'); + + return; + } + + $host = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST); + if (! is_string($host) || $host === '') { + return; + } + + $this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host; + } + + public function cancelEdit(): void + { + $this->editingIndex = null; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->resetErrorBag('editingDomainParts.host'); + } + + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->preview->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + + public function removeDomain(int $index): void + { + $this->authorize('update', $this->preview->application); + if (! isset($this->domainRows[$index])) { + return; + } + if ($this->editingIndex === $index) { + $this->editingIndex = null; + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); + } elseif ($this->editingIndex !== null && $this->editingIndex > $index) { + $this->editingIndex--; + } + unset($this->domainRows[$index]); + $this->domainRows = array_values($this->domainRows); + if (! $this->persistDomains()) { + return; + } + $this->dispatch('success', 'Domain removed.'); + } + + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service'])) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + public function checkAllDns(): void + { + $this->authorize('update', $this->preview->application); + foreach (array_keys($this->domainRows) as $index) { + $this->queueDnsCheck($index); + } + } + + public function checkDomainDns(int $index): void + { + $this->authorize('update', $this->preview->application); + $this->queueDnsCheck($index); + } + + private function queueDnsCheck(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + + $row = $this->domainRows[$index]; + $checkId = new_public_id(); + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + $this->persistDnsStatuses(); + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($row['url'], $row['service']), + $row['url'], + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + } + } + + public function pollDnsChecks(): void + { + $this->authorize('view', $this->preview->application); + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + private function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + + private function applyDnsCheck(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + $result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index); + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + } + + private function checkUrlDns(string $url, string $key = 'domain'): array + { + $server = $this->preview->application->destination?->server; + + return CheckDomainDns::run( + [$key => $url], + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $this->preview->application->additional_servers->count() > 0, + )[$key]; + } + + private function refreshDomains(): void + { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->preview->refresh(); + $statuses = $this->preview->domain_dns_statuses ?? []; + $rows = []; + if ($this->preview->application->build_pack === 'dockercompose') { + foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) { + foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) { + $rows[] = $this->makeRow($url, (string) $service, $statuses); + } + } + } else { + foreach ($this->splitDomains($this->preview->fqdn) as $url) { + $rows[] = $this->makeRow($url, null, $statuses); + } + } + $this->domainRows = $rows; + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && $row['service'] === $editingRow['service']); + $this->editingIndex = $index === false ? null : (int) $index; + } + } + + private function persistDomains(): bool + { + if ($this->preview->application->build_pack === 'dockercompose') { + try { + $composeServices = $this->composeServices(failOnError: true); + } catch (\Throwable) { + $this->refreshDomains(); + $this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.'); + + return false; + } + $existingDomains = json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: []; + $domains = []; + foreach ($composeServices as $service) { + $domains[$service] = is_array($existingDomains[$service] ?? null) ? $existingDomains[$service] : []; + $domains[$service]['domain'] = ''; + } + $validRows = collect($this->domainRows) + ->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true)); + foreach ($validRows->groupBy('service') as $service => $rows) { + $domains[$service]['domain'] = $rows->pluck('url')->implode(','); + } + $this->preview->docker_compose_domains = json_encode($domains); + $this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null; + } else { + $this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null; + } + $normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides); + $this->preview->fqdn = $normalized['fqdn']; + $this->preview->domain_port_overrides = $normalized['overrides']; + if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) { + foreach ($domains as $service => $entry) { + $serviceDomains = $this->splitDomains(composeDomainEntryString($entry)); + $domains[$service]['domain'] = collect($serviceDomains) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->implode(','); + } + $this->preview->docker_compose_domains = json_encode($domains); + } + foreach ($this->domainRows as $index => $row) { + $this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']); + } + $this->preview->save(); + $this->persistDnsStatuses(); + $this->refreshDomains(); + $this->dispatch('update_links'); + $this->dispatch('previewDomainsChanged'); + + return true; + } + + private function persistDnsStatuses(): void + { + $statuses = []; + foreach ($this->domainRows as $row) { + $statuses[$this->statusKey($row['url'], $row['service'])] = [ + 'status' => $row['dns_status'], + 'message' => $row['dns_message'], + 'check_id' => $row['check_id'] ?? null, + ]; + } + + DB::transaction(function () use (&$statuses): void { + $preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id); + $storedStatuses = $preview->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $storedStatus = $storedStatuses[$key] ?? null; + if (! is_array($storedStatus)) { + continue; + } + + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatus['check_id'] ?? null; + + if (($storedCheckId !== null && $localCheckId !== $storedCheckId) + || ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) { + $statuses[$key] = $storedStatus; + } + } + + $preview->domain_dns_statuses = $statuses ?: null; + $preview->save(); + }); + + $this->preview->domain_dns_statuses = $statuses ?: null; + } + + private function validatedDomain(array $parts, string $errorKey): ?string + { + $domain = DomainUrlParts::compose(...$parts); + $validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]); + if ($validator->fails()) { + $this->addError($errorKey, $validator->errors()->first('domain')); + + return null; + } + + return ValidationPatterns::normalizeApplicationDomains($domain); + } + + private function makeRow(string $url, ?string $service, array $statuses = []): array + { + $status = $statuses[$this->statusKey($url, $service)] ?? []; + $port = $this->effectiveDomainInternalPort($url, $service); + $redirect = 'both'; + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { + $usesPreviewRedirect = (int) $this->preview->application->compose_parsing_version >= 3; + $domains = json_decode(($usesPreviewRedirect + ? $this->preview->docker_compose_domains + : $this->preview->application->docker_compose_domains) ?: '[]', true) ?: []; + $storedRedirect = $usesPreviewRedirect + ? ($domains[$service]['redirect'] ?? null) + : data_get($domains, "$service.redirect"); + $redirect = in_array($storedRedirect, ['www', 'non-www', 'both'], true) ? $storedRedirect : 'both'; + } + + return [ + 'url' => $url, + 'service' => $service, + 'redirect' => $redirect, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], + 'dns_status' => $status['status'] ?? 'pending', + 'dns_message' => $status['message'] ?? 'DNS has not been checked yet.', + 'check_id' => $status['check_id'] ?? null, + ]; + } + + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + private function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + private function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->preview->application->portRequiresConfirmation($port, $serviceName); + } + + private function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + private function effectiveDomainInternalPort(string $url, ?string $service = null): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->preview->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + $composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + + if ($this->preview->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $exposed = $this->preview->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + + private function statusKey(string $url, ?string $service): string + { + return hash('sha256', $url.'|'.($service ?? '')); + } + + private function splitDomains(?string $domains): array + { + return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all(); + } + + private function generateComposeDomains(string $service): array + { + $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; + $domainString = getComposeServiceDomainString($applicationDomains, $service); + + if (empty($domainString)) { + $domainString = generateUrl( + server: $this->preview->application->destination->server, + random: str($service)->slug().'-'.$this->preview->application->uuid, + ); + } + + return collect($this->splitDomains($domainString))->map(function (string $domain): string { + $generated = $this->preview->generatedPreviewDomain($domain); + if (filled($generated['port'])) { + $overrides = $this->preview->domain_port_overrides ?? []; + $overrides[$generated['url']] = $generated['port']; + $this->preview->domain_port_overrides = $overrides; + } + + return $generated['url']; + })->all(); + } + + private function composeServices(bool $failOnError = false): array + { + try { + $parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); + $services = data_get($parsedCompose, 'services', []); + if (! is_iterable($services)) { + return []; + } + + $previewSuffix = '-pr-'.$this->preview->pull_request_id; + $serviceNames = []; + foreach ($services as $serviceName => $service) { + if (isDatabaseImage(data_get($service, 'image'))) { + continue; + } + + $serviceName = (string) $serviceName; + if (str_ends_with($serviceName, $previewSuffix)) { + $serviceName = substr($serviceName, 0, -strlen($previewSuffix)); + } + $serviceNames[] = $serviceName; + } + + return array_values(array_unique($serviceNames)); + } catch (\Throwable $exception) { + if ($failOnError) { + throw $exception; + } + + return []; + } + } +} diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 3944bbe09d..79a393c192 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -7,7 +7,6 @@ use App\Events\ServiceStatusChanged; use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; -use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -16,8 +15,14 @@ class Previews extends Component { use AuthorizesRequests; + protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains']; + public Application $application; + public bool $isPreviewDeploymentsEnabled = false; + + public bool $isPrDeploymentsPublicEnabled = false; + public string $deployment_uuid; public array $parameters; @@ -26,16 +31,6 @@ class Previews extends Component public int $rate_limit_remaining; - public $domainConflicts = []; - - public $showDomainConflictModal = false; - - public $forceSaveDomains = false; - - public $pendingPreviewId = null; - - public array $previewFqdns = []; - public array $previewDockerTags = []; public ?int $manualPullRequestId = null; @@ -43,7 +38,6 @@ class Previews extends Component public ?string $manualDockerTag = null; protected $rules = [ - 'previewFqdns.*' => 'string|nullable', 'previewDockerTags.*' => 'string|nullable', 'manualPullRequestId' => 'integer|min:1|nullable', 'manualDockerTag' => 'string|nullable', @@ -51,33 +45,51 @@ class Previews extends Component public function mount() { + $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->pull_requests = collect(); $this->parameters = get_route_parameters(); - $this->syncData(false); + $this->syncDockerTags(); } - private function syncData(bool $toModel = false): void + public function savePreviewSettings(): void { - if ($toModel) { - foreach ($this->previewFqdns as $key => $fqdn) { - $preview = $this->application->previews->get($key); - if ($preview) { - $preview->fqdn = $fqdn; - if ($this->application->build_pack === 'dockerimage') { - $preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null; - } - } - } - } else { - $this->previewFqdns = []; - $this->previewDockerTags = []; - foreach ($this->application->previews as $key => $preview) { - $this->previewFqdns[$key] = $preview->fqdn; - $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; - } + $this->authorize('update', $this->application); + $this->validate([ + 'isPreviewDeploymentsEnabled' => 'boolean', + 'isPrDeploymentsPublicEnabled' => 'boolean', + ]); + + $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; + $this->application->settings->save(); + + $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); + } + + public function togglePreviewDeployments(): void + { + $this->authorize('update', $this->application); + + $this->isPreviewDeploymentsEnabled = ! $this->isPreviewDeploymentsEnabled; + $this->savePreviewSettings(); + } + + private function syncDockerTags(): void + { + $this->previewDockerTags = []; + foreach ($this->application->previews as $key => $preview) { + $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; } } + public function refreshPreviewDomains(): void + { + $this->application->refresh(); + $this->syncDockerTags(); + } + public function load_prs() { try { @@ -92,103 +104,28 @@ class Previews extends Component } } - public function confirmDomainUsage() - { - $this->forceSaveDomains = true; - $this->showDomainConflictModal = false; - if ($this->pendingPreviewId) { - $this->save_preview($this->pendingPreviewId); - $this->pendingPreviewId = null; - } - } - public function save_preview($preview_id) { try { $this->authorize('update', $this->application); - $success = true; $preview = $this->application->previews->find($preview_id); if (! $preview) { throw new \Exception('Preview not found'); } - // Find the key for this preview in the collection $previewKey = $this->application->previews->search(function ($item) use ($preview_id) { return $item->id == $preview_id; }); - if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { - $this->validate([ - "previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(), - ]); - - $fqdn = $this->previewFqdns[$previewKey]; - - if (! empty($fqdn)) { - $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn); - $this->previewFqdns[$previewKey] = $fqdn; - - if (! validateDNSEntry($fqdn, $this->application->destination->server)) { - $server = $this->application->destination->server; - $target = serverDnsTargetIp($server) ?? $server->ip; - $guidance = dnsMismatchGuidanceMessage($target, $target); - $this->dispatch('error', 'Validating DNS failed.', "{$guidance}

Check this documentation for further help."); - $success = false; - } - - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application, domain: $fqdn); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - $this->pendingPreviewId = $preview_id; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - } + if ($previewKey === false) { + throw new \Exception('Preview not found'); } - if ($success) { - $this->syncData(true); - $preview->save(); - $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); - } - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate_preview($preview_id) - { - try { - $this->authorize('update', $this->application); - - $preview = $this->application->previews->find($preview_id); - if (! $preview) { - $this->dispatch('error', 'Preview not found.'); - - return; - } - if ($this->application->build_pack === 'dockercompose') { - $preview->generate_preview_fqdn_compose(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('success', 'Domain generated.'); - - return; - } - - $preview->generate_preview_fqdn(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); + $this->validateOnly("previewDockerTags.{$previewKey}"); + $preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null; + $preview->save(); + $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -211,7 +148,7 @@ class Previews extends Component } $found->generate_preview_fqdn_compose(); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -227,9 +164,9 @@ class Previews extends Component $found->docker_registry_image_tag = $docker_registry_image_tag; $found->save(); } - $found->generate_preview_fqdn(); + $found->generate_preview_fqdn(generateWithoutApplicationDomain: true); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview added.'); } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php deleted file mode 100644 index 0fdcf46153..0000000000 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ /dev/null @@ -1,165 +0,0 @@ -domain = data_get($this->service, 'domain'); - } - - public function render() - { - return view('livewire.project.application.previews-compose'); - } - - public function save() - { - try { - $this->authorize('update', $this->preview->application); - $this->validate([ - 'domain' => ValidationPatterns::applicationDomainRules(), - ]); - - $this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain); - $this->persistPreviewDomain($this->domain); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate() - { - try { - $this->authorize('update', $this->preview->application); - - $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; - $domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName); - - // If no domain is set in the main application, generate a default domain - if (empty($domain_string)) { - $server = $this->preview->application->destination->server; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - // Generate a unique domain like main app services do - $generated_fqdn = generateUrl(server: $server, random: $random); - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; - } else { - foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) { - throw new \InvalidArgumentException($error); - } - - // Use the existing domain from the main application - // Handle multiple domains separated by commas - $domain_list = ValidationPatterns::applicationDomainList($domain_string); - $preview_fqdns = []; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - foreach ($domain_list as $single_domain) { - $single_domain = trim($single_domain); - if (empty($single_domain)) { - continue; - } - - $url = Url::fromString($single_domain); - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdns[] = "$schema://$preview_fqdn{$port}"; - } - - $preview_fqdn = implode(',', $preview_fqdns); - } - - $this->domain = $preview_fqdn; - $this->persistPreviewDomain($this->domain); - - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - private function persistPreviewDomain(?string $domain): void - { - $docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []; - $serviceNames = $this->previewServiceNames($docker_compose_domains); - $storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames) - ?? (string) $this->serviceName; - - $docker_compose_domains = putComposeServiceDomain( - $docker_compose_domains, - $storageKey, - $domain, - $serviceNames, - ); - $docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames); - - $this->serviceName = $storageKey; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); - } - - /** - * @param array $previewDomains - * @return list - */ - private function previewServiceNames(array $previewDomains): array - { - $parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); - $fromCompose = collect(data_get($parsedServices, 'services', [])) - ->keys() - ->map(function ($serviceName) { - return str((string) $serviceName) - ->replaceLast('-pr-'.$this->preview->pull_request_id, '') - ->toString(); - }) - ->all(); - - $domainKeys = collect(array_keys($previewDomains)) - ->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [])) - ->map(fn ($name) => (string) $name); - $unmapped = $domainKeys - ->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null) - ->all(); - - return collect($fromCompose) - ->merge(preferredComposeServiceNamesFromDomainKeys( - $fromCompose === [] ? $domainKeys->all() : $unmapped - )) - ->unique() - ->values() - ->all(); - } -} diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 29f798d595..60a7955738 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -65,7 +65,7 @@ class Source extends Component $this->gitCommitSha = trim($this->gitCommitSha); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php index 661578fb3d..ac867e69aa 100644 --- a/app/Livewire/Project/Application/Swarm.php +++ b/app/Livewire/Project/Application/Swarm.php @@ -31,7 +31,7 @@ class Swarm extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Application/TrafficOverview.php b/app/Livewire/Project/Application/TrafficOverview.php new file mode 100644 index 0000000000..223c6047ae --- /dev/null +++ b/app/Livewire/Project/Application/TrafficOverview.php @@ -0,0 +1,73 @@ +application->destination?->server; + $this->serverUuid = $server?->uuid; + $this->enabled = (bool) $server?->isTrafficAnalyticsEnabled(); + $this->eligible = $server ? (! $server->isSwarm() && ! $server->isBuildServer()) : false; + + if ($this->enabled && $server) { + try { + $client = app(SentinelTrafficClient::class, ['server' => $server]); + $this->overview = $client->appOverview($this->application->uuid, '24h')->toArray(); + } catch (\Throwable $e) { + $this->overview = null; + } + } + } + + public function hasData(): bool + { + return $this->overview !== null && (int) ($this->overview['requests'] ?? 0) > 0; + } + + public function errorRate(): float + { + if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { + return 0.0; + } + + $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); + + return round(($errors / $this->overview['requests']) * 100, 2); + } + + public function placeholder(): string + { + return <<<'HTML' +
+ HTML; + } + + public function render() + { + return view('livewire.project.application.traffic-overview'); + } +} diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 608d153ebc..04e86e261e 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -85,6 +85,9 @@ class BackupEdit extends Component #[Validate(['required', 'int', 'min:60', 'max:36000'])] public int|string $timeout = 3600; + #[Validate(['required', 'integer', 'min:0', 'max:365'])] + public int $missingBackupNotificationDays = 0; + public function getListeners(): array { // Keep "Backup Now" in sync when the database starts/stops without a full page refresh. @@ -128,7 +131,7 @@ class BackupEdit extends Component $this->status = $database->status; } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->backup->enabled = $this->backupEnabled; @@ -152,6 +155,7 @@ class BackupEdit extends Component $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; + $this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays; $this->customValidate(); $this->backup->save(); } else { @@ -170,12 +174,14 @@ class BackupEdit extends Component $this->databasesToBackup = $this->backup->databases_to_backup; $this->dumpAll = $this->backup->dump_all; $this->timeout = $this->backup->timeout; + $this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days; } } public function delete($password, $selectedActions = []) { - $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database; + $this->authorize('manageBackups', $database); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; @@ -183,10 +189,10 @@ class BackupEdit extends Component try { $server = null; - if ($this->backup->database instanceof ServiceDatabase) { - $server = $this->backup->database->service->destination->server; - } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { - $server = $this->backup->database->destination->server; + if ($database instanceof ServiceDatabase) { + $server = $database->service->destination->server; + } elseif ($database->destination && $database->destination->server) { + $server = $database->destination->server; } $filenames = $this->backup->executions() @@ -207,9 +213,9 @@ class BackupEdit extends Component } } - $database = $this->backup->database; $backupUuid = $this->backup->uuid; $this->backup->delete(); + $this->skipRender(); auditLog('ui.database.backup_schedule_deleted', [ 'team_id' => $database->team()?->id, 'database_uuid' => $database->uuid, @@ -217,17 +223,15 @@ class BackupEdit extends Component 'backup_uuid' => $backupUuid, ]); - if ($database->getMorphClass() === ServiceDatabase::class) { - $serviceDatabase = $database; - - return redirect()->route('project.service.database.backups', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'environment_uuid' => $this->parameters['environment_uuid'], - 'service_uuid' => $serviceDatabase->service->uuid, - 'stack_service_uuid' => $serviceDatabase->uuid, + if ($database instanceof ServiceDatabase) { + return redirectRoute($this, 'project.service.database.backups', [ + 'project_uuid' => $database->service->project()->uuid, + 'environment_uuid' => $database->service->environment->uuid, + 'service_uuid' => $database->service->uuid, + 'stack_service_uuid' => $database->uuid, ]); } else { - return redirect()->route('project.database.backup.index', [ + return redirectRoute($this, 'project.database.backup.index', [ 'project_uuid' => $this->parameters['project_uuid'], 'environment_uuid' => $this->parameters['environment_uuid'], 'database_uuid' => $this->parameters['database_uuid'], @@ -245,6 +249,14 @@ class BackupEdit extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + $this->status = $database->status; + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $database = $this->backup->database; auditLog('ui.database.backup_started', [ diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 73877a945e..2786a45c3d 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -6,7 +6,6 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class BackupExecutions extends Component @@ -37,12 +36,12 @@ class BackupExecutions extends Component public $delete_backup_sftp = false; - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); + $teamId = currentTeam()->id; return [ - "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', + "echo-private:team.{$teamId},BackupCreated" => 'refreshBackupExecutions', ]; } diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e45c797d1e..39a1960119 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -17,6 +17,13 @@ class BackupNow extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $database = $this->backup->database; auditLog('ui.database.backup_started', [ diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index ad5e45b3fe..1d8354a4fb 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -121,7 +121,7 @@ class General extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -199,6 +199,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index b4236b215a..96d2ac7aaf 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -85,11 +85,10 @@ class CreateScheduledBackup extends Component $databaseBackup = ScheduledDatabaseBackup::create($payload); if ($database->getMorphClass() === ServiceDatabase::class) { $service = $database->service; - $this->redirectRoute('project.service.database.backup.show', [ + $this->redirectRoute('project.service.volume-backups.index', [ 'project_uuid' => $service->project()->uuid, 'environment_uuid' => $service->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $databaseBackup->uuid, ], navigate: true); } else { diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 2f5b844845..a8bde2f007 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -115,7 +115,7 @@ class General extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -191,6 +191,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 4b34c5e4ee..9a7a8634aa 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -6,9 +6,11 @@ use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; +use App\Enums\ProcessStatus; use App\Events\ServiceStatusChanged; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; +use Spatie\Activitylog\Models\Activity; class Heading extends Component { @@ -20,6 +22,10 @@ class Heading extends Component public $docker_cleanup = true; + public $isDeploymentProgress = false; + + public $runningActivityId = null; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -35,6 +41,12 @@ class Heading extends Component public function activityFinished() { + if (auth()->user()->cannot('update', $this->database)) { + $this->dispatch('refresh'); + + return; + } + try { // Only set started_at if database is actually running if ($this->database->isRunning()) { @@ -55,6 +67,8 @@ class Heading extends Component public function checkStatus() { + $this->checkDeployments(); + if ($this->database->destination->server->isFunctional()) { GetContainersStatus::dispatch($this->database->destination->server); } else { @@ -62,6 +76,52 @@ class Heading extends Component } } + public function checkDeployments() + { + try { + $activity = Activity::where('properties->type_uuid', $this->database->uuid)->latest()->first(); + $status = data_get($activity, 'properties.status'); + if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) { + $this->isDeploymentProgress = true; + $this->runningActivityId = $activity->id; + } else { + $this->isDeploymentProgress = false; + $this->runningActivityId = null; + } + } catch (\Throwable) { + $this->isDeploymentProgress = false; + $this->runningActivityId = null; + } + + return $this->isDeploymentProgress; + } + + /** + * Re-attach the live log dialog to a start/restart that is already running, + * so the log reappears after the dialog was closed. + */ + public function reopenDeployment() + { + $this->authorize('view', $this->database); + + $this->checkDeployments(); + + if ($this->isDeploymentProgress && $this->runningActivityId) { + $this->dispatch('activityMonitor', $this->runningActivityId, ServiceStatusChanged::class); + $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); + } else { + $this->dispatch('info', 'No operation is currently running.'); + } + } + + private function markDeploymentRunning($activity): void + { + if (is_object($activity)) { + $this->isDeploymentProgress = true; + $this->runningActivityId = $activity->id; + } + } + public function manualCheckStatus() { $this->checkStatus(); @@ -74,6 +134,8 @@ class Heading extends Component 'environment_uuid' => $this->database->environment->uuid, 'database_uuid' => $this->database->uuid, ]; + + $this->checkDeployments(); } public function stop() @@ -96,6 +158,7 @@ class Heading extends Component $activity = RestartDatabase::run($this->database); $this->auditDatabaseAction('ui.database.restarted'); + $this->markDeploymentRunning($activity); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -110,6 +173,7 @@ class Heading extends Component $activity = StartDatabase::run($this->database); $this->auditDatabaseAction('ui.database.started'); + $this->markDeploymentRunning($activity); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Database/Health.php b/app/Livewire/Project/Database/Health.php index 8943e6316e..07373bdba2 100644 --- a/app/Livewire/Project/Database/Health.php +++ b/app/Livewire/Project/Database/Health.php @@ -34,7 +34,7 @@ class Health extends Component $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 87c9328eb3..e5a359b30d 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database; +use App\Actions\Database\StartDatabaseImport; use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; @@ -10,12 +11,13 @@ 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\Rules\SafeWebhookUrl; -use App\Support\DatabaseBackupFileValidator; +use App\Support\DatabaseImport\DatabaseImportCommandBuilder; +use App\Support\DatabaseImport\DatabaseImportException; +use App\Support\DatabaseImport\DatabaseImportSource; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Storage; @@ -158,13 +160,15 @@ class ImportForm extends Component public bool $dumpAll = false; + public bool $replaceExisting = false; + public string $restoreCommandText = ''; public string $customLocation = ''; public ?int $activityId = null; - public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + public string $postgresqlRestoreCommand = 'pg_restore --exit-on-error -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; @@ -276,13 +280,24 @@ createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} EOD; $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; } else { - $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + $this->syncPostgresqlRestoreCommand(); } break; } } + public function updatedReplaceExisting(): void + { + $this->syncPostgresqlRestoreCommand(); + } + + private function syncPostgresqlRestoreCommand(): void + { + $replaceExisting = $this->replaceExisting ? ' --clean --if-exists' : ''; + $this->postgresqlRestoreCommand = 'pg_restore --exit-on-error'.$replaceExisting.' -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } + public function getContainers() { $this->containers = []; @@ -446,78 +461,25 @@ EOD; try { $this->importRunning = true; - $this->importCommands = []; - $backupFileName = "upload/{$this->resourceUuid}/restore"; - - // Check if an uploaded file exists first (takes priority over custom location) - if (Storage::exists($backupFileName)) { - $path = Storage::path($backupFileName); - - // Reject malicious PostgreSQL payloads before transferring the file anywhere. - if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) { - Storage::delete($backupFileName); - $this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.'); - - return true; - } - - $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; - instant_scp($path, $tmpPath, $this->server); - Storage::delete($backupFileName); - $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; - $this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath); - } elseif (filled($this->customLocation)) { - // Validate the custom location to prevent command injection - if (! $this->validateServerPath($this->customLocation)) { - $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); - - return true; - } - $tmpPath = '/tmp/restore_'.$this->resourceUuid; - $escapedCustomLocation = escapeshellarg($this->customLocation); - $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; - $this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath); - } else { - $this->dispatch('error', 'The file does not exist or has been deleted.'); - - return true; - } - - // Copy the restore command to a script file - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - $restoreCommand = $this->buildRestoreCommand($tmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; - $this->importCommands[] = "chmod +x {$scriptPath}"; - $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; - - $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'"; - $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - if (! empty($this->importCommands)) { - $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [ - 'scriptPath' => $scriptPath, - 'tmpPath' => $tmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID - $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over - $this->dispatch('activityMonitor', $activity->id); - $this->dispatch('databaserestore'); - auditLog('ui.database.import_started', [ - 'team_id' => $this->resource->team()?->id, - 'database_uuid' => $this->resource->uuid, - 'database_name' => $this->resource->name, - 'source' => 'file', - ]); - } + $source = Storage::exists("upload/{$this->resourceUuid}/restore") + ? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting) + : new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting); + $activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id); + $this->activityId = $activity->id; + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + auditLog('ui.database.import_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 'file', + 'replace_existing' => $this->replaceExisting, + ]); + } catch (DatabaseImportException $e) { + $this->importRunning = false; + $this->dispatch('error', $e->getMessage()); } catch (\Throwable $e) { + $this->importRunning = false; handleError($e, $this); return true; @@ -588,7 +550,7 @@ EOD; // Validate bucket name early if (! $this->validateBucketName($s3Storage->bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.'); + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.'); return; } @@ -660,118 +622,9 @@ EOD; try { $this->importRunning = true; - - $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); - - $key = $s3Storage->key; - $secret = $s3Storage->secret; - $bucket = $s3Storage->bucket; - $endpoint = $s3Storage->endpoint; - - // Validate bucket name to prevent command injection - if (! $this->validateBucketName($bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.'); - - return true; - } - - // Clean the S3 path - $cleanPath = ltrim($this->s3Path, '/'); - - // Validate the S3 path to prevent command injection - if (! $this->validateS3Path($cleanPath)) { - $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return true; - } - - // Get helper image - $helperImage = coolifyHelperImage(); - $latestVersion = getHelperVersion(); - $fullImageName = "{$helperImage}:{$latestVersion}"; - - // Get the database destination network - if ($this->resource->getMorphClass() === ServiceDatabase::class) { - $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; - } else { - $destinationNetwork = $this->resource->destination->network ?? 'coolify'; - } - - // Generate unique names for this operation - $containerName = "s3-restore-{$this->resourceUuid}"; - $helperTmpPath = '/tmp/'.basename($cleanPath); - $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); - $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - $escapedServerTmpPath = escapeshellarg($serverTmpPath); - $escapedContainerTmpPath = escapeshellarg($containerTmpPath); - $escapedScriptPath = escapeshellarg($scriptPath); - $escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}"); - $escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}"); - $escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}"); - $restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}"); - - // Prepare all commands in sequence - $commands = []; - - // 1. Clean up any existing helper container and temp files from previous runs - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true"; - $commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true"; - - // 2. Start helper container on the database network - $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; - - // 3. Configure S3 access in helper container - $escapedEndpoint = escapeshellarg($endpoint); - $escapedKey = escapeshellarg($key); - $escapedSecret = escapeshellarg($secret); - $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - - // 4. Check file exists in S3 (bucket and path already validated above) - $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); - $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; - - // 5. Download from S3 to helper container (progress shown by default) - $escapedHelperTmpPath = escapeshellarg($helperTmpPath); - $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; - - // 6. Copy from helper to server, then immediately to database container - $commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}"; - $commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}"; - $this->addRestoreSafetyCheckCommand($commands, $containerTmpPath); - - // 7. Cleanup helper container and server temp file immediately (no longer needed) - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true"; - - // 8. Build and execute restore command inside database container - $restoreCommand = $this->buildRestoreCommand($containerTmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}"; - $commands[] = "chmod +x {$escapedScriptPath}"; - $commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}"; - - // 9. Execute restore and cleanup temp files immediately after completion - $commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}"; - $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - // Execute all commands with cleanup event (as safety net for edge cases) - $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ - 'containerName' => $containerName, - 'serverTmpPath' => $serverTmpPath, - 'scriptPath' => $scriptPath, - 'containerTmpPath' => $containerTmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID + $source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting); + $activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id); $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); auditLog('ui.database.restore_started', [ @@ -779,9 +632,13 @@ EOD; 'database_uuid' => $this->resource->uuid, 'database_name' => $this->resource->name, 'source' => 's3', + 'replace_existing' => $this->replaceExisting, 'storage_id' => $this->s3StorageId, ]); $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); + } catch (DatabaseImportException $e) { + $this->importRunning = false; + $this->dispatch('error', $e->getMessage()); } catch (\Throwable $e) { $this->importRunning = false; handleError($e, $this); @@ -792,147 +649,8 @@ EOD; return true; } - public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string - { - $script = $this->buildPostgresRestoreScanScript($tmpPath); - - if ($script === null) { - return null; - } - - return "docker exec {$this->container} sh -c ".escapeshellarg($script); - } - - /** - * Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL - * backup contains directives leading to OS command execution. - * - * Hardened against bypasses: - * - decompresses gzip backups before scanning, - * - converts custom-format (PGDMP) archives to SQL with pg_restore - * before scanning, and rejects archives that cannot be inspected, - * - strips `--` line comments and flattens newlines so multi-line and - * comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught, - * - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects. - */ - public function buildPostgresRestoreScanScript(string $tmpPath): ?string - { - if (! $this->isPostgresqlRestore()) { - return null; - } - - $escapedTmpPath = escapeshellarg($tmpPath); - - // Token separator PostgreSQL treats as whitespace: real whitespace or a - // /* ... */ block comment (used to split keywords like FROM/**/PROGRAM). - $sep = '([[:space:]]|/\\*[^*]*\\*/)'; - - $sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program"; - $psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)"; - $escapedSqlPattern = escapeshellarg($sqlPattern); - $escapedPsqlPattern = escapeshellarg($psqlPattern); - $contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }"; - $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}"; - $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); - $sqlScan = $scan($contents); - $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1'; - $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; - - return << "\$inspect"; then - {$blockedInspect} - fi - if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then - {$blockedInspect} - fi - if {$customScan}; then - {$blockedProgram} - fi -elif {$sqlScan}; then - {$blockedProgram} -fi -SH; - } - - private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void - { - $command = $this->buildRestoreSafetyCheckCommand($tmpPath); - - if ($command !== null) { - $commands[] = $command; - } - } - - private function isPostgresqlRestore(): bool - { - $morphClass = $this->resource->getMorphClass(); - - if ($morphClass === ServiceDatabase::class) { - return str_contains($this->resource->databaseType(), 'postgres'); - } - - return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql'; - } - public function buildRestoreCommand(string $tmpPath): string { - $escapedTmpPath = escapeshellarg($tmpPath); - $morphClass = $this->resource->getMorphClass(); - - // Handle ServiceDatabase by checking the database type - if ($morphClass === ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'mysql')) { - $morphClass = 'mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $morphClass = 'mariadb'; - } elseif (str_contains($dbType, 'postgres')) { - $morphClass = 'postgresql'; - } elseif (str_contains($dbType, 'mongo')) { - $morphClass = 'mongodb'; - } - } - - switch ($morphClass) { - case StandaloneMariadb::class: - case 'mariadb': - $restoreCommand = $this->mariadbRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$escapedTmpPath}"; - } - break; - case StandaloneMysql::class: - case 'mysql': - $restoreCommand = $this->mysqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$escapedTmpPath}"; - } - break; - case StandalonePostgresql::class: - case 'postgresql': - $restoreCommand = $this->postgresqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi"; - } else { - $restoreCommand .= " {$escapedTmpPath}"; - } - break; - case StandaloneMongodb::class: - case 'mongodb': - $restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath; - break; - default: - $restoreCommand = ''; - } - - return $restoreCommand; + return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting); } } diff --git a/app/Livewire/Project/Database/InitScript.php b/app/Livewire/Project/Database/InitScript.php index 7074c235d5..eba1c4d8f7 100644 --- a/app/Livewire/Project/Database/InitScript.php +++ b/app/Livewire/Project/Database/InitScript.php @@ -22,6 +22,9 @@ class InitScript extends Component #[Locked] public int $index; + #[Locked] + public string $originalFilename; + #[Validate(['nullable', 'string'])] public ?string $filename = null; @@ -33,6 +36,7 @@ class InitScript extends Component try { $this->index = data_get($this->script, 'index'); $this->filename = data_get($this->script, 'filename'); + $this->originalFilename = (string) data_get($this->script, 'filename'); $this->content = data_get($this->script, 'content'); } catch (Exception $e) { return handleError($e, $this); @@ -47,7 +51,7 @@ class InitScript extends Component $this->script['index'] = $this->index; $this->script['content'] = $this->content; $this->script['filename'] = $this->filename; - $this->dispatch('save_init_script', $this->script); + $this->dispatch('save_init_script', $this->script, $this->originalFilename); } catch (Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index b2d9bce91b..0398362bbb 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -118,7 +118,7 @@ class General extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -196,6 +196,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 61280a34b5..4d2dd9d8c2 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -136,7 +136,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -244,6 +244,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index f68ba82c7d..d3545564ee 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -128,7 +128,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -237,6 +237,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 1adfe2ea79..ce7fc01ecd 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -136,7 +136,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -244,6 +244,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 051fb515d9..3d0406956f 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -149,7 +149,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -240,6 +240,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -247,16 +248,16 @@ class General extends Component } } - public function save_init_script($script) + public function save_init_script($script, string $originalFilename) { $this->authorize('update', $this->database); $initScripts = collect($this->initScripts ?? []); $existingScript = $initScripts->firstWhere('filename', $script['filename']); - $oldScript = $initScripts->firstWhere('index', $script['index']); + $oldScript = $initScripts->firstWhere('filename', $originalFilename); - if ($existingScript && $existingScript['index'] !== $script['index']) { + if ($existingScript && $script['filename'] !== $originalFilename) { $this->dispatch('error', 'A script with this filename already exists.'); return; @@ -285,11 +286,10 @@ class General extends Component } } - $index = $initScripts->search(function ($item) use ($script) { - return $item['index'] === $script['index']; - }); + $index = $initScripts->search(fn ($item) => $item['filename'] === $originalFilename); if ($index !== false) { + $script['index'] = $oldScript['index']; $initScripts[$index] = $script; } else { $initScripts->push($script); diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index d431b15064..7c6313c8da 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -127,7 +127,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -235,6 +235,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index 91b0444f51..0d42c71e94 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -77,7 +77,7 @@ class Edit extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index 9b9a3670db..35db3167d3 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -48,7 +48,7 @@ class EnvironmentEdit extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 2b472a1a20..b1f00b147d 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -53,13 +53,11 @@ class Index extends Component 'uuid' => $project->uuid, 'name' => $project->name, 'description' => $project->description, - 'iconUrl' => $project->icon_path ? route('project.icon', [ - 'project_uuid' => $project->uuid, - 'v' => $project->updated_at->timestamp, - ]) : null, + 'iconUrl' => $project->icon_path ? project_icon_url($project) : null, 'href' => $project->navigateTo(), 'environmentCount' => $project->environments->count(), 'resourceCount' => $resourceCount, + 'createdAt' => $project->created_at?->format('M j, Y') ?? '-', 'settingsHref' => auth()->user()->can('update', $project) ? route('project.edit', ['project_uuid' => $project->uuid]) : null, diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 502a69bec4..6946d9a687 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -125,7 +125,10 @@ class GithubPrivateRepositoryDeployKey extends Component public function setPrivateKey($private_key_id) { - $this->private_key_id = $private_key_id; + $this->authorize('create', Application::class); + + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($private_key_id); + $this->private_key_id = $privateKey->id; $this->current_step = 'repository'; } @@ -133,6 +136,8 @@ class GithubPrivateRepositoryDeployKey extends Component { $this->authorize('create', Application::class); + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $this->validate(); try { $destination_uuid = $this->query['destination'] ?? null; @@ -160,7 +165,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, - 'private_key_id' => $this->private_key_id, + 'private_key_id' => $privateKey->id, ]; } else { $application_init = [ @@ -173,7 +178,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, - 'private_key_id' => $this->private_key_id, + 'private_key_id' => $privateKey->id, 'source_id' => $this->git_source->id, 'source_type' => $this->git_source->getMorphClass(), ]; @@ -216,6 +221,14 @@ class GithubPrivateRepositoryDeployKey extends Component throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); } + if (($scp = parseScpStyleGitUrl($this->repository_url)) !== null) { + $this->git_host = $scp['host']; + $this->git_repository = $this->repository_url; + $this->git_source = 'other'; + + return; + } + $this->repository_url_parsed = Url::fromString($this->repository_url); $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 81d65bc857..a031c50c00 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -137,10 +137,9 @@ class PublicGitRepository extends Component throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); } - if (str($this->repository_url)->startsWith('git@')) { - $github_instance = str($this->repository_url)->after('git@')->before(':'); - $repository = str($this->repository_url)->after(':')->before('.git'); - $this->repository_url = 'https://'.str($github_instance).'/'.$repository; + $httpsRepositoryUrl = scpStyleGitUrlToHttps($this->repository_url); + if (is_string($httpsRepositoryUrl)) { + $this->repository_url = $httpsRepositoryUrl; } if ( (str($this->repository_url)->startsWith('https://') || diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 4cffff8001..307fbb8936 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -111,38 +111,14 @@ class Select extends Component $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { - $default_logo = 'svgs/default.webp'; - $logo = data_get($service, 'logo'); - - if (is_string($logo) && str_starts_with($logo, 'svg/')) { - $normalizedLogo = 'svgs/'.str($logo)->after('svg/'); - if (file_exists(public_path($normalizedLogo))) { - $logo = $normalizedLogo; - } - } - - $hasLogo = is_string($logo) - && basename($logo) !== basename($default_logo) - && file_exists(public_path($logo)); - - if (! $hasLogo) { - $logo = $default_logo; - } - - $local_logo_path = public_path($logo); $serviceKey = (string) $key; return [ 'id' => $serviceKey, 'name' => str($serviceKey)->headline(), 'docsSlug' => str($serviceKey)->lower()->value(), - 'has_logo' => $hasLogo, - 'logo' => asset($logo), - 'logo_github_url' => file_exists($local_logo_path) - ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo - : asset($default_logo), 'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null, - ] + (array) $service; + ] + service_logo_urls(data_get($service, 'logo')) + (array) $service; })->all(); // Extract unique categories from services @@ -298,7 +274,7 @@ class Select extends Component $this->servers = $this->allServers; } else { if ($this->allServers instanceof Collection) { - $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false); + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources()); } else { $this->servers = $this->allServers; } @@ -396,7 +372,7 @@ class Select extends Component $this->isDatabase = true; $this->includeSwarm = false; if ($this->allServers instanceof Collection) { - $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false); + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources()); } else { $this->servers = $this->allServers; } @@ -406,7 +382,7 @@ class Select extends Component $this->isDatabase = true; $this->includeSwarm = false; if ($this->allServers instanceof Collection) { - $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false); + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources()); } else { $this->servers = $this->allServers; } diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 93633246a8..7c375d0e03 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -187,6 +187,11 @@ class Index extends Component 'fqdn' => $item->fqdn ?? null, 'description' => $item->description ?? null, 'status' => $item->status ?? '', + 'restartLimitReached' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit(), + 'restartCount' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit() + ? max($item->restart_count ?? 0, $item->max_restart_count ?? 0) + : ($item->restart_count ?? 0), + 'maxRestartCount' => $item->max_restart_count ?? 0, 'server_status' => $item->server_status ?? null, 'hrefLink' => $item->hrefLink ?? '', 'destination' => [ diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php new file mode 100644 index 0000000000..6d7fb97f68 --- /dev/null +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -0,0 +1,156 @@ +id; + + return [ + 'modalClosed' => 'closeExecutionModal', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(Service $service): void + { + abort_unless($service->environment?->project?->team_id === currentTeam()->id, 404); + $this->service = $service; + $this->authorize('view', $this->service); + } + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage('executionsPage'); + } + + public function openExecution(string $executionUuid): void + { + $this->authorize('view', $this->service); + $execution = $this->executionQuery($executionUuid)->first(); + abort_unless($execution, 404); + $this->selectedExecution = $this->formatExecutions(collect([$execution]))->first(); + $this->executionModalOpen = true; + } + + public function closeExecutionModal(): void + { + $this->executionModalOpen = false; + $this->selectedExecution = null; + } + + public function render(): View + { + $this->authorize('view', $this->service); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + if ($executions->currentPage() > $executions->lastPage()) { + $this->setPage($executions->lastPage(), 'executionsPage'); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + } + $executions->setCollection($this->formatExecutions($executions->getCollection())); + + return view('livewire.project.service.backup-executions', [ + 'executions' => $executions, + ]); + } + + private function executionQuery(?string $uuid = null): Builder + { + $databaseScheduleIds = ScheduledDatabaseBackup::query() + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->select('id'); + $volumeScheduleIds = ScheduledVolumeBackup::query() + ->forService($this->service) + ->select('id'); + + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->select('id', 'uuid', 'created_at') + ->selectRaw("'database' as type") + ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->select('id', 'uuid', 'created_at') + ->selectRaw("'storage' as type") + ->whereIn('scheduled_volume_backup_id', $volumeScheduleIds) + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + + return $databaseExecutions->toBase() + ->unionAll($volumeExecutions->toBase()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->orderBy('type'); + } + + private function formatExecutions(Collection $rows): Collection + { + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3']) + ->whereIn('id', $rows->where('type', 'database')->pluck('id')) + ->get()->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->with(['scheduledVolumeBackup.backupable.resource', 's3']) + ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) + ->get()->keyBy('id'); + + return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array { + $isDatabase = $row->type === 'database'; + $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); + $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; + $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; + if ($storage?->team_id !== currentTeam()->id) { + $storage = null; + } + $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; + if ($isDatabase && ! $schedule->save_s3) { + $storageLabel = 'Not configured'; + } elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + $storageLabel = 'No destination recorded'; + } + + return [ + 'id' => $row->type.':'.$execution->id, + 'uuid' => $execution->uuid, + 'target' => $isDatabase ? ($schedule->database->human_name ?: $schedule->database->name) : $schedule->targetName(), + 'type' => $isDatabase ? 'Database' : $schedule->targetType(), + 'schedule' => $schedule->frequency, + 's3_tooltip' => ($isDatabase ? 'Current schedule S3 storage: ' : 'S3 storage: ').$storageLabel, + 'status' => $execution->status, + 'started_at' => $execution->created_at, + 'size' => $execution->size, + 'message' => $execution->message, + 'filename' => $execution->filename, + 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + ? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id) + : null, + ]; + }); + } +} diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 90907abc6e..8535584dd8 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -22,8 +22,6 @@ class DatabaseBackups extends Component public array $query; - public bool $isImportSupported = false; - public ?ScheduledDatabaseBackup $backup = null; public string $section = 'index'; @@ -32,7 +30,7 @@ class DatabaseBackups extends Component protected $listeners = ['refreshScheduledBackups' => '$refresh']; - public function mount() + public function mount(): mixed { try { $this->parameters = array_filter( @@ -67,10 +65,13 @@ class DatabaseBackups extends Component return redirect()->route('project.service.index', $this->parameters); } - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + if (! request()->route('backup_uuid')) { + return redirect()->route('project.service.volume-backups.index', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } if (request()->route('backup_uuid')) { $this->backup = $this->serviceDatabase->scheduledBackups() @@ -85,6 +86,14 @@ class DatabaseBackups extends Component 'project.service.database.backup.danger' => 'danger', default => 'general', }; + + $routeParameters = [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]; + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index d932e76494..79c89322a7 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -5,12 +5,15 @@ namespace App\Livewire\Project\Service; use App\Actions\Shared\CheckDomainDns; use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; +use App\Livewire\Concerns\InteractsWithDnsProviders; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -20,6 +23,7 @@ class Domains extends Component { use AuthorizesRequests; use InteractsWithCloudflareDomainConnect; + use InteractsWithDnsProviders; protected bool $notifyRedirectUpdate = true; @@ -62,6 +66,16 @@ class Domains extends Component public ?int $editingServiceApplicationId = null; + public string $editingIndexing = 'index'; + + public string $editingRedirect = 'both'; + + public string $editingOriginalRedirect = 'both'; + + public bool $editingDomainWasRegenerated = false; + + public ?string $editingGeneratedHost = null; + public bool $showEditDomainModal = false; public bool $forceSaveDomains = false; @@ -107,11 +121,20 @@ class Domains extends Component 'confirmDomainUsage', ]; + public function getListeners(): array + { + return array_merge($this->listeners, [ + 'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished', + ]); + } + protected function rules(): array { return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), + 'editingIndexing' => 'string|required|in:index,noindex', + 'editingRedirect' => 'string|required|in:both,www,non-www', 'newServiceApplicationId' => 'nullable|integer', 'serviceRedirects' => 'array', 'serviceRedirects.*' => 'string|in:both,www,non-www', @@ -128,9 +151,17 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->service->refresh(); $this->service->load(['applications', 'server']); $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && (int) $row['service_application_id'] === (int) $editingRow['service_application_id']); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -238,9 +269,14 @@ class Domains extends Component ]) ->all(); + $pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null; $this->serviceRedirects = []; foreach ($this->service->applications as $app) { - $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + $this->serviceRedirects[$app->id] = $this->normalizeRedirect( + $this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId + ? $pendingRedirect + : $app->redirect + ); } if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { @@ -306,30 +342,15 @@ class Domains extends Component { $entry = $stored[$url] ?? null; $displayName = $app->human_name ?: $app->name; + $port = $this->effectiveDomainInternalPort($url, $app); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'service_application_id' => $app->id, - 'service_name' => $displayName, - 'service_image' => $app->image, - 'url' => $url, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'check_id' => data_get($entry, 'check_id'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'service_application_id' => $app->id, 'service_name' => $displayName, 'service_image' => $app->image, 'url' => $url, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, @@ -340,6 +361,48 @@ class Domains extends Component 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url, ServiceApplication $app): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $app->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + + if (array_key_exists($canonical, $overrides)) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null && $legacyPort > 0) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + $requiredPort = $app->getRequiredPort(); + + return [ + 'internal_port' => ($requiredPort !== null && $requiredPort > 0) ? $requiredPort : null, + 'has_port_override' => false, + ]; } /** @@ -431,39 +494,20 @@ class Domains extends Component $this->authorize('update', $this->service); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->service->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->service); - $this->isCheckingDns = true; - - try { - $server = $this->service->server; - $skipDns = ! $this->dnsValidationEnabled || ! $server; - - $indexesToCheck = []; - - foreach ($this->domainRows as $index => $row) { - if ($skipDns) { - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = ! $this->dnsValidationEnabled - ? 'DNS validation is disabled in instance settings.' - : 'No server available for DNS validation.'; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - - continue; - } - - $indexesToCheck[] = $index; + foreach ($this->domainRows as $row) { + $application = $this->findServiceApp((int) $row['service_application_id']); + if ($application) { + $this->queueUrlsDns([$row['url']], $application); } - - if ($server && $indexesToCheck !== []) { - $this->applyDnsStatuses($indexesToCheck, $server); - } - - $this->persistAllDomainDnsStatuses(); - } finally { - $this->isCheckingDns = false; } } @@ -475,19 +519,11 @@ class Domains extends Component return; } - $server = $this->service->server; - if (! $server || ! $this->dnsValidationEnabled) { - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = 'DNS check skipped.'; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->decorateSuggestedDomainAfterDnsCheck($index); - $this->persistAllDomainDnsStatuses(); - - return; + $row = $this->domainRows[$index]; + $application = $this->findServiceApp((int) $row['service_application_id']); + if ($application) { + $this->queueUrlsDns([$row['url']], $application); } - - $this->applyDnsStatus($index, $server); - $this->persistAllDomainDnsStatuses(); } protected function applyDnsStatus(int $index, Server $server): void @@ -536,6 +572,11 @@ class Domains extends Component $this->domainRows[$index]['suggestion_role'] = $meta['role']; } + protected function persistDomainDnsStatuses(): void + { + $this->persistAllDomainDnsStatuses(); + } + protected function persistAllDomainDnsStatuses(): void { $byApp = []; @@ -791,7 +832,7 @@ class Domains extends Component $this->dispatch('configurationChanged'); $this->pruneDomainDnsStatusesToCurrentDomains(); $this->refreshDomains(); - $this->checkUrlsDns($addedDomains, $serviceApplicationId); + $this->queueUrlsDns($addedDomains, $app); } catch (\Throwable $e) { handleError($e, $this); } @@ -891,6 +932,7 @@ class Domains extends Component } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -907,6 +949,11 @@ class Domains extends Component continue; } + $port = $this->effectiveDomainInternalPort($url, $app); + if ($port['has_port_override']) { + $portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port']; + } + $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } @@ -915,12 +962,13 @@ class Domains extends Component return true; } + $app->domain_port_overrides = $portOverrides ?: null; $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectServiceApplicationId = $app->id; - // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged)) { + // Counterparts inherit an existing port, so only domain conflicts need confirmation. + if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) { return false; } @@ -947,11 +995,24 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect($this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } public function cancelRemovePort(): void { + $this->authorize('update', $this->service); + + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $app = $this->findServiceApp($this->pendingRedirectServiceApplicationId); + $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect); + } + $this->pendingRedirectServiceApplicationId = null; $this->showPortWarningModal = false; $this->forceSaveDomains = false; $this->forceRemovePort = false; @@ -990,8 +1051,11 @@ class Domains extends Component ->all() : []; $current = collect($this->splitDomains($app->fqdn)); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured for this service."); return; @@ -1016,9 +1080,15 @@ class Domains extends Component $this->pendingAction = null; $this->dispatch('close-modal'); $this->refreshDomains(); - $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + if ($this->configureDnsAfterDomainAdd($addedUrls)) { + $this->dispatch('success', 'Domain added.'); + + return; + } + $serviceApplicationId = (int) $app->id; - $dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [ + $dnsChecks = collect($addedUrls)->map(fn (string $url) => [ 'url' => $url, 'check_id' => new_public_id(), ]); @@ -1111,8 +1181,18 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $internalPort = $this->domainRows[$index]['internal_port'] ?? null; + if (filled($internalPort)) { + $this->editingDomainParts['port'] = (string) $internalPort; + } $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; + $app = $this->findServiceApp($this->editingServiceApplicationId); + $this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index'; + $this->editingRedirect = $this->serviceRedirectFor($this->editingServiceApplicationId); + $this->editingOriginalRedirect = $this->editingRedirect; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; @@ -1129,6 +1209,11 @@ class Domains extends Component $this->editingDomainParts = DomainUrlParts::empty(); $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = null; + $this->editingIndexing = 'index'; + $this->editingRedirect = 'both'; + $this->editingOriginalRedirect = 'both'; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; @@ -1144,10 +1229,20 @@ class Domains extends Component return; } - if ($this->editingDomainPartsChanged) { - $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + $editingDomainParts = $this->editingDomainParts; + $editingRow = $this->domainRows[$this->editingIndex]; + if ( + ! ($editingRow['has_port_override'] ?? false) + && (string) ($editingDomainParts['port'] ?? '') === (string) ($editingRow['internal_port'] ?? '') + ) { + $editingDomainParts['port'] = ''; + } + if ($this->editingDomainPartsChanged || filled($editingDomainParts['host'] ?? null)) { + $this->editingDomain = DomainUrlParts::compose(...$editingDomainParts); } $this->validateOnly('editingDomain'); + $this->validateOnly('editingIndexing'); + $this->validateOnly('editingRedirect'); $app = $this->findServiceApp($this->editingServiceApplicationId); if (! $app) { @@ -1164,39 +1259,66 @@ class Domains extends Component $newUrl = $this->splitDomains($normalized)[0]; $oldUrl = $this->domainRows[$this->editingIndex]['url']; $current = collect($this->splitDomains($app->fqdn)); - $wasNoindexed = $app->isDomainNoindexed($oldUrl); + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $app->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $app->domain_port_overrides = $portOverrides ?: null; + } - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured for this service."); return; } - if (! $this->forceSaveEditDns && $this->shouldValidateDns()) { - $dnsFailure = $this->findDnsFailureMessage([$newUrl]); - if ($dnsFailure !== null) { - $this->editDomainDnsFailed = true; - $this->editDomainDnsMessage = $dnsFailure; - $this->showEditDomainModal = true; - - return; + $replacements = [$oldUrl => $newUrl]; + if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + $oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST); + $oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost); + if (is_string($oldCounterpart)) { + $parts = DomainUrlParts::split($oldCounterpart); + $port = ($app->domain_port_overrides ?? [])[DomainPortOverrides::withoutPort($oldCounterpart)] ?? null; + $parts['port'] = filled($port) ? (string) $port : $parts['port']; + $parts['host'] = str_starts_with(strtolower($parts['host']), 'www.') ? 'www.'.$this->editingGeneratedHost : $this->editingGeneratedHost; + $replacements[$oldCounterpart] = DomainUrlParts::compose(...$parts); + } + } + $updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values(); + if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + foreach ($updated->all() as $url) { + $counterpart = $this->wwwCounterpartUrl($url, true); + $host = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null; + if (filled($counterpart) && ! $updated->contains(fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $host)) { + $updated->push($counterpart); + } + } + } + $urlsToCheck = $updated + ->reject(fn (string $url): bool => $current->contains( + fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url) + )) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->unique() + ->values() + ->all(); + $noindexDomains = $app->noindexDomains(); + foreach ($replacements as $previousUrl => $replacementUrl) { + $isNoindexed = $previousUrl === $oldUrl ? $this->editingIndexing === 'noindex' : $app->isDomainNoindexed($previousUrl); + $noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl); + if ($isNoindexed) { + $noindexDomains->push($replacementUrl); } } - - $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); $this->pendingAction = 'update'; - if (! $this->saveDomainListForApp($app, $updated)) { + if (! $this->saveDomainListForApp($app, $updated, noindexDomains: $noindexDomains, redirect: $this->editingRedirect)) { return; } - $noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); - if ($wasNoindexed) { - $noindexDomains->push($newUrl); - } - $app->setNoindexDomains($noindexDomains); - $app->save(); - $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->forceSaveDomains = false; @@ -1204,13 +1326,15 @@ class Domains extends Component $this->pendingAction = null; $this->dispatch('success', 'Domain updated.'); $this->refreshDomains(); - $this->checkUrlsDns([$newUrl], (int) $app->id); + if ($urlsToCheck !== []) { + $this->queueUrlsDns($urlsToCheck, $app); + } } catch (\Throwable $e) { handleError($e, $this); } } - public function removeDomain(int $index): void + public function removeDomain(int $index, string $password = '', array $selectedActions = []): void { try { $this->authorize('update', $this->service); @@ -1233,6 +1357,10 @@ class Domains extends Component return; } + if (in_array('deleteManagedDns', $selectedActions, true)) { + $this->deleteManagedDnsForUrl($url); + } + $this->forceSaveDomains = false; $this->forceRemovePort = false; $this->dispatch('success', 'Domain removed.'); @@ -1243,6 +1371,40 @@ class Domains extends Component } } + public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => ! ($row['is_suggested'] ?? false) + && hash_equals($domainKey, $this->domainRowKey($row)) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index, $password, $selectedActions); + } + + /** + * @param array{url: string, service_application_id: int|string} $row + */ + private function domainRowKey(array $row): string + { + return hash('sha256', $row['url'].'|'.$row['service_application_id']); + } + + protected function dnsResourceForHostname(string $hostname): ?Model + { + foreach ($this->domainRows as $row) { + $rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST); + if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) { + return $this->findServiceApp((int) $row['service_application_id']); + } + } + + return null; + } + public function addSuggestedDomain(int $index): void { try { @@ -1340,6 +1502,24 @@ class Domains extends Component } } + public function regenerateEditingDomain(): void + { + $this->authorize('update', $this->service); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex]) || ! $this->service->server) { + return; + } + + $host = parse_url(generateUrl(server: $this->service->server, random: new_public_id()), PHP_URL_HOST); + if (! is_string($host) || $host === '') { + return; + } + + $this->editingGeneratedHost = $host; + $this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host; + $this->editingDomainPartsChanged = true; + $this->editingDomainWasRegenerated = true; + } + /** * @param Collection $domains */ @@ -1347,6 +1527,9 @@ class Domains extends Component ServiceApplication $app, Collection $domains, bool $checkConflicts = true, + bool $checkPorts = true, + ?Collection $noindexDomains = null, + ?string $redirect = null, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1361,6 +1544,12 @@ class Domains extends Component } $app->fqdn = $domainString; + if ($noindexDomains !== null) { + $app->setNoindexDomains($noindexDomains); + } + if ($redirect !== null) { + $app->redirect = $redirect; + } if ($checkConflicts && ! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $app); @@ -1373,11 +1562,12 @@ class Domains extends Component } } - if (! $this->forceRemovePort) { + if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { + $previousFqdn = $app->getOriginal('fqdn'); foreach ($this->splitDomains($domainString) as $fqdn) { - if (ServiceApplication::extractPortFromUrl($fqdn) === null) { + if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) { $this->requiredPort = $requiredPort; $this->showPortWarningModal = true; $app->refresh(); @@ -1425,6 +1615,7 @@ class Domains extends Component $urlSet = array_fill_keys($urls, true); $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; @@ -1458,6 +1649,33 @@ class Domains extends Component $this->persistAllDomainDnsStatuses(); } + /** + * @param array $urls + */ + protected function queueUrlsDns(array $urls, ServiceApplication $application): void + { + foreach (array_unique($urls) as $url) { + $checkId = new_public_id(); + $this->markUrlsAsChecking([$url], (int) $application->id, $checkId); + $this->persistAllDomainDnsStatuses(); + + try { + CheckDomainDnsJob::dispatch( + $application, + $url, + $url, + $this->service->server, + $this->serverIp, + $checkId, + ); + } catch (\Throwable) { + $this->markUrlsDnsCheckUnavailable([$url], (int) $application->id, $checkId); + $this->persistAllDomainDnsStatuses(); + $this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.'); + } + } + } + protected function shouldValidateDns(): bool { return $this->dnsValidationEnabled && $this->service->server !== null; diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index 46a8ecdc89..2feafe41a2 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -78,7 +78,7 @@ class EditCompose extends Component try { $this->authorize('update', $this->service); $this->dispatch('saveCompose', $this->dockerComposeRaw); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 96fe6a62c3..f099891534 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -46,18 +46,18 @@ class EditDomain extends Component $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); // Sync to model - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); $this->application->save(); } else { // Sync from model - $this->fqdn = $this->application->fqdn; + $this->fqdn = $this->application->url; } } @@ -84,6 +84,10 @@ class EditDomain extends Component public function submit() { try { + $persistedApplication = $this->application->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->application); $this->validate(); @@ -93,7 +97,7 @@ class EditDomain extends Component $this->dispatch('warning', __('warning.sslipdomain')); } // Sync to model for domain conflict check (without validation) - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); // Check for domain conflicts if not forcing save if (! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); @@ -113,29 +117,21 @@ class EditDomain extends Component $requiredPort = $this->application->getRequiredPort(); if ($requiredPort !== null) { - // Check if all FQDNs have a port - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->application->fqdn = $previousFqdn; + $this->application->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { // Reset the force flag after using it diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 84a0daec8a..cd209f6ae9 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -120,7 +120,7 @@ class FileStorage extends Component : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { if ($this->fileStorage->is_too_large) { @@ -160,7 +160,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -179,7 +179,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -207,7 +207,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -242,7 +242,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } return true; @@ -308,10 +308,10 @@ class FileStorage extends Component { return view('livewire.project.service.file-storage', [ 'directoryDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'], ], 'fileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'], ], 'hostFileDeletionCheckboxes' => [ ['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'], diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 072a56e002..33fce9f079 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -5,8 +5,11 @@ namespace App\Livewire\Project\Service; use App\Actions\Docker\GetContainersStatus; use App\Actions\Service\StartService; use App\Actions\Service\StopService; +use App\Actions\Service\StopServiceApplication; use App\Enums\ProcessStatus; use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -24,6 +27,8 @@ class Heading extends Component public $isDeploymentProgress = false; + public $runningActivityId = null; + public $docker_cleanup = true; public $title = 'Configuration'; @@ -32,6 +37,8 @@ class Heading extends Component { $this->authorizeService('view'); + $this->checkDeployments(); + if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -54,6 +61,8 @@ class Heading extends Component { $this->authorizeService('view'); + $this->checkDeployments(); + if ($this->service->server->isFunctional()) { GetContainersStatus::dispatch($this->service->server); } else { @@ -98,22 +107,46 @@ class Heading extends Component $status = data_get($activity, 'properties.status'); if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) { $this->isDeploymentProgress = true; + $this->runningActivityId = $activity->id; } else { $this->isDeploymentProgress = false; + $this->runningActivityId = null; } } catch (\Throwable) { $this->isDeploymentProgress = false; + $this->runningActivityId = null; } return $this->isDeploymentProgress; } + /** + * Re-attach the live log dialog to a deployment that is already running. + * Used by the "Deploying…" indicator and when Deploy/Restart is clicked + * while a deployment is in progress, so the running log reappears instead + * of a dead-end error. + */ + public function reopenDeployment() + { + $this->authorizeService('view'); + + $this->checkDeployments(); + + if ($this->isDeploymentProgress && $this->runningActivityId) { + $this->dispatch('activityMonitor', $this->runningActivityId); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); + } else { + $this->dispatch('info', 'No deployment is currently running.'); + } + } + public function start() { try { $this->authorizeService('deploy'); $activity = StartService::run($this->service, pullLatestImages: true); $this->auditServiceAction('ui.service.started'); + $this->markDeploymentRunning($activity->id); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -135,6 +168,7 @@ class Heading extends Component $activity->save(); } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->markDeploymentRunning($activity->id); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -142,6 +176,12 @@ class Heading extends Component } } + private function markDeploymentRunning($activityId): void + { + $this->isDeploymentProgress = true; + $this->runningActivityId = $activityId; + } + public function stop() { try { @@ -165,6 +205,7 @@ class Heading extends Component } $activity = StartService::run($this->service, stopBeforeStart: true); $this->auditServiceAction('ui.service.restarted'); + $this->markDeploymentRunning($activity->id); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -172,6 +213,29 @@ class Heading extends Component } } + public function removeSelectedResourceContainer(): void + { + $resource = $this->selectedResource(); + if (! $resource) { + return; + } + + $this->authorize('update', $resource); + StopServiceApplication::run($resource, true, true); + $this->dispatch('success', 'Container removed.'); + } + + private function selectedResource(): ServiceApplication|ServiceDatabase|null + { + $uuid = data_get($this->parameters, 'stack_service_uuid'); + if (! $uuid) { + return null; + } + + return $this->service->applications()->whereUuid($uuid)->first() + ?? $this->service->databases()->whereUuid($uuid)->first(); + } + public function pullAndRestartEvent() { try { @@ -184,6 +248,7 @@ class Heading extends Component } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $this->auditServiceAction('ui.service.restarted'); + $this->markDeploymentRunning($activity->id); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Service/ImportBackup.php b/app/Livewire/Project/Service/ImportBackup.php new file mode 100644 index 0000000000..29e8d9f359 --- /dev/null +++ b/app/Livewire/Project/Service/ImportBackup.php @@ -0,0 +1,80 @@ +parameters = get_route_parameters(); + $project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); + $this->authorize('update', $this->service); + + $this->databases = $this->service->databases + ->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database)) + ->values(); + + $databaseUuid = request()->route('stack_service_uuid'); + if ($databaseUuid) { + $selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid); + abort_unless($selectedDatabase instanceof ServiceDatabase, 404); + $this->authorize('update', $selectedDatabase); + $this->selectedDatabase = $selectedDatabase; + $this->selectedDatabaseUuid = $selectedDatabase->uuid; + + if (request()->routeIs('project.service.database.import')) { + return redirect()->route('project.service.import-backup.database', $this->parameters); + } + } elseif ($this->databases->count() === 1) { + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $this->databases->first()->uuid, + ]); + } + + return null; + } + + public function updatedSelectedDatabaseUuid(): mixed + { + $database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid); + abort_unless($database instanceof ServiceDatabase, 404); + $this->authorize('update', $database); + + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $database->uuid, + ]); + } + + public function render(): View + { + return view('livewire.project.service.import-backup'); + } + + private function supportsImport(ServiceDatabase $database): bool + { + return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']); + } +} diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index d93ed7c026..f4b6bec2d4 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -20,7 +20,7 @@ class Index extends Component public ?Service $service = null; - public ?ServiceApplication $serviceApplication = null; + public ServiceApplication|ServiceDatabase|null $serviceApplication = null; public ?ServiceDatabase $serviceDatabase = null; @@ -28,6 +28,8 @@ class Index extends Component public ?string $currentRoute = null; + public bool $embedded = false; + public array $parameters; public array $query; @@ -59,8 +61,6 @@ class Index extends Component public bool $isLogDrainEnabled = false; - public bool $isImportSupported = false; - // Application-specific properties public $docker_cleanup = true; @@ -84,6 +84,8 @@ class Index extends Component public bool $isStripprefixEnabled = false; + public mixed $maxRestartCount = 0; + protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages']; protected $rules = [ @@ -95,16 +97,57 @@ class Index extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', + 'maxRestartCount' => 'integer|min:0', // Application-specific rules 'fqdn' => 'nullable', 'isGzipEnabled' => 'nullable|boolean', 'isStripprefixEnabled' => 'nullable|boolean', ]; - public function mount() - { + public function mount( + ServiceApplication|ServiceDatabase|null $serviceApplication = null, + bool $embedded = false, + ) { try { + $this->embedded = $embedded; $this->services = collect([]); + if ($serviceApplication instanceof ServiceDatabase) { + $this->service = $serviceApplication->service; + $this->authorize('view', $this->service); + $this->parameters = [ + 'project_uuid' => $this->service->environment->project->uuid, + 'environment_uuid' => $this->service->environment->uuid, + 'service_uuid' => $this->service->uuid, + 'stack_service_uuid' => $serviceApplication->uuid, + ]; + $this->query = request()->query(); + $this->currentRoute = 'project.service.index'; + $this->serviceDatabase = $serviceApplication; + $this->serviceApplication = null; + $this->resourceType = 'database'; + $this->initializeDatabaseProperties(); + $this->s3s = currentTeam()->s3s; + + return; + } + if ($serviceApplication) { + $this->service = $serviceApplication->service; + $this->authorize('view', $this->service); + $this->parameters = [ + 'project_uuid' => $this->service->environment->project->uuid, + 'environment_uuid' => $this->service->environment->uuid, + 'service_uuid' => $this->service->uuid, + 'stack_service_uuid' => $serviceApplication->uuid, + ]; + $this->query = request()->query(); + $this->currentRoute = 'project.service.index'; + $this->serviceApplication = $serviceApplication; + $this->resourceType = 'application'; + $this->initializeApplicationProperties(); + $this->s3s = currentTeam()->s3s; + + return; + } $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->currentRoute = request()->route()->getName(); @@ -119,6 +162,12 @@ class Index extends Component ->firstOrFail(); $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); + if (in_array($this->currentRoute, ['project.service.index', 'project.service.index.advanced'], true)) { + return redirect()->route( + 'project.service.configuration', + collect($this->parameters)->except('stack_service_uuid')->all(), + ); + } $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { $this->serviceApplication = $service; @@ -153,10 +202,6 @@ class Index extends Component $this->refreshFileStorages(); $this->syncDatabaseData(false); - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); } private function syncDatabaseData(bool $toModel = false): void @@ -296,36 +341,48 @@ class Index extends Component public function instantSave() { + $this->authorize('update', $this->serviceDatabase); try { - $this->authorize('update', $this->serviceDatabase); - if ($this->isPublic && ! $this->publicPort) { - $this->dispatch('error', 'Public port is required.'); - $this->isPublic = false; - - return; - } - $this->syncDatabaseData(true); - if ($this->serviceDatabase->is_public) { - if (! str($this->serviceDatabase->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); + if ($this->isPublic) { + if (! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; - $this->serviceDatabase->is_public = false; return; } + if (! str($this->serviceDatabase->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + + return; + } + $this->persistPublicAccess(); StartDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); $this->dispatch('success', 'Database is now publicly accessible.'); } else { + $this->persistPublicAccess(); StopDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = null; $this->dispatch('success', 'Database is no longer publicly accessible.'); } } catch (\Throwable $e) { + $this->isPublic = ! $this->isPublic; + $this->persistPublicAccess(); + return handleError($e, $this); } } + private function persistPublicAccess(): void + { + $this->serviceDatabase->update([ + 'is_public' => $this->isPublic, + 'public_port' => $this->publicPort ?: null, + 'public_port_timeout' => $this->publicPortTimeout ?: null, + ]); + } + public function submitDatabase() { try { @@ -356,24 +413,43 @@ class Index extends Component if ($toModel) { $this->serviceApplication->human_name = $this->humanName; $this->serviceApplication->description = $this->description; - $this->serviceApplication->fqdn = $this->fqdn; + $this->serviceApplication->setEditableUrls($this->fqdn); $this->serviceApplication->image = $this->image; $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + if ($this->serviceApplication->max_restart_count !== (int) $this->maxRestartCount) { + $this->serviceApplication->restart_limit_reached = false; + } + $this->serviceApplication->max_restart_count = $this->maxRestartCount; } else { $this->humanName = $this->serviceApplication->human_name; $this->description = $this->serviceApplication->description; - $this->fqdn = $this->serviceApplication->fqdn; + $this->fqdn = $this->serviceApplication->url; $this->image = $this->serviceApplication->image; $this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false); $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); $this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true); $this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true); + $this->maxRestartCount = $this->serviceApplication->max_restart_count ?? 0; } } + public function saveMaxRestartCount(): void + { + $this->authorize('update', $this->serviceApplication); + $validated = $this->validate([ + 'maxRestartCount' => 'integer|min:0', + ]); + + $this->serviceApplication->update([ + 'max_restart_count' => $validated['maxRestartCount'], + 'restart_limit_reached' => false, + ]); + $this->dispatch('success', 'Max restart count saved.'); + } + public function instantSaveApplication() { try { @@ -428,7 +504,7 @@ class Index extends Component $this->serviceApplication->delete(); $this->dispatch('success', 'Application deleted.'); - return redirect()->route('project.service.configuration', $this->parameters); + return redirectRoute($this, 'project.service.configuration', $this->parameters); } catch (\Throwable $e) { return handleError($e, $this); } @@ -462,7 +538,7 @@ class Index extends Component $serviceApplication->delete(); }); - return redirect()->route('project.service.configuration', $redirectParams); + return redirectRoute($this, 'project.service.configuration', $redirectParams); } catch (\Throwable $e) { return handleError($e, $this); } @@ -491,6 +567,10 @@ class Index extends Component public function submitApplication() { try { + $persistedApplication = $this->serviceApplication->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->serviceApplication); $this->validate([ 'fqdn' => ValidationPatterns::applicationDomainRules(), @@ -520,28 +600,21 @@ class Index extends Component $requiredPort = $this->serviceApplication->getRequiredPort(); if ($requiredPort !== null) { - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->serviceApplication->fqdn = $previousFqdn; + $this->serviceApplication->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { $this->forceRemovePort = false; diff --git a/app/Livewire/Project/Service/Status.php b/app/Livewire/Project/Service/Status.php index 192d7ca804..27919f1ae7 100644 --- a/app/Livewire/Project/Service/Status.php +++ b/app/Livewire/Project/Service/Status.php @@ -10,6 +10,13 @@ class Status extends Component { public Service $service; + public ?string $selectedResourceUuid = null; + + public function mount(): void + { + $this->selectedResourceUuid = request()->route('stack_service_uuid'); + } + public function getListeners(): array { $teamId = auth()->user()->currentTeam()->id; @@ -27,6 +34,11 @@ class Status extends Component public function render(): View { - return view('livewire.project.service.status'); + $selectedResource = $this->selectedResourceUuid + ? $this->service->applications->firstWhere('uuid', $this->selectedResourceUuid) + ?? $this->service->databases->firstWhere('uuid', $this->selectedResourceUuid) + : null; + + return view('livewire.project.service.status', compact('selectedResource')); } } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 6880b5ab09..10079276f2 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Project\Shared\Storages\All as StorageList; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -51,7 +52,7 @@ class Storage extends Component return [ "echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent', - 'refreshStorages', + 'storageCountsChanged' => 'refreshStorages', 'addNewVolume', ]; } @@ -88,11 +89,17 @@ class Storage extends Component public function refreshStorages() { + $hadVolumes = $this->cachedVolumeCount > 0; + // Avoid loading full volume models onto this parent (child All owns that snapshot). $this->resource->unsetRelation('persistentStorages'); $this->loadVolumeCount(); $this->loadFileStorageMetaCounts(); $this->loadFileStorageForActiveTab(); + + if ($this->activeTab === 'volumes' && $hadVolumes && $this->cachedVolumeCount > 0) { + $this->dispatch('refreshVolumeList')->to(StorageList::class); + } } public function setActiveTab(string $tab): void @@ -222,7 +229,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'Volume added successfully'); $this->dispatch('closeStorageModal', 'volume'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -257,7 +263,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'File mount added successfully'); $this->dispatch('closeStorageModal', 'file'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -292,7 +297,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'Host file mount added successfully'); $this->dispatch('closeStorageModal', 'host-file'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -331,7 +335,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'Directory mount added successfully'); $this->dispatch('closeStorageModal', 'directory'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 49da9e21f4..600b8b9047 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -2,12 +2,16 @@ namespace App\Livewire\Project\Service\VolumeBackup; +use App\Jobs\DatabaseBackupJob; +use App\Jobs\VolumeBackupJob; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Url; use Livewire\Component; class Index extends Component @@ -20,14 +24,85 @@ class Index extends Component public string $search = ''; - protected $listeners = ['refreshVolumeBackups' => '$refresh']; + #[Url(as: 'backup_uuid', except: '')] + public string $backupUuid = ''; - public function mount(): void + public bool $scheduleModalOpen = false; + + public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; + + public ?ScheduledVolumeBackup $selectedVolumeBackup = null; + + public ?Collection $s3s = null; + + public function getListeners(): array { - $this->service = $this->findService(); + $teamId = currentTeam()->id; + + return [ + 'refreshVolumeBackups' => '$refresh', + 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},ServiceChecked" => '$refresh', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(?Service $service = null): void + { + $this->service = $service ?? $this->findService(); $this->authorize('view', $this->service); $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + + if ($this->backupUuid !== '') { + $this->openSchedule($this->backupUuid); + } + } + + public function openSchedule(string $backupUuid): void + { + $this->authorize('update', $this->service); + $this->loadSelectedSchedule($backupUuid); + $this->s3s = currentTeam()->s3s; + $this->scheduleModalOpen = true; + } + + public function closeScheduleModal(): void + { + $this->backupUuid = ''; + $this->scheduleModalOpen = false; + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + } + + public function backupNow(string $type, string $backupUuid): void + { + try { + if ($type === 'database') { + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedDatabaseBackup, 404); + $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + if (! str($this->selectedDatabaseBackup->database->status)->startsWith('running')) { + $this->selectedDatabaseBackup = null; + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); + } else { + abort_unless($type === 'storage', 404); + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedVolumeBackup, 404); + $this->authorize('update', $this->selectedVolumeBackup->targetResource()); + VolumeBackupJob::dispatch($this->selectedVolumeBackup); + } + + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + $this->dispatch('success', 'Backup queued.'); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function render(): View @@ -68,4 +143,24 @@ class Index extends Component ->where('uuid', request()->route('service_uuid')) ->firstOrFail(); } + + private function loadSelectedSchedule(string $backupUuid): void + { + $this->selectedDatabaseBackup = ScheduledDatabaseBackup::query() + ->with('database') + ->whereUuid($backupUuid) + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->first(); + + if ($this->selectedDatabaseBackup) { + return; + } + + $this->selectedVolumeBackup = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->whereUuid($backupUuid) + ->forService($this->service) + ->firstOrFail(); + } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Show.php b/app/Livewire/Project/Service/VolumeBackup/Show.php index eec60497f7..10abeb3bf4 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Show.php +++ b/app/Livewire/Project/Service/VolumeBackup/Show.php @@ -20,7 +20,7 @@ class Show extends Component public string $section = 'general'; - public function mount(): void + public function mount(): mixed { $project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail(); $environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); @@ -43,6 +43,10 @@ class Show extends Component 'project.service.volume-backups.danger' => 'danger', default => 'general', }; + + $routeParameters = collect($this->parameters)->except('backup_uuid')->all(); + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } public function render(): View diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 7f0d3b173e..d2420a029f 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -106,14 +106,13 @@ class Danger extends Component try { $this->authorize('delete', $this->resource); - $this->resource->delete(); DeleteResourceJob::dispatch( $this->resource, $this->delete_volumes, $this->delete_connected_networks, $this->delete_configurations, $this->docker_cleanup - ); + )->afterResponse(); return redirectRoute($this, 'project.resource.index', [ 'project_uuid' => $this->projectUuid, diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 9262b9847e..4118c0c7ab 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -157,7 +157,9 @@ class Destination extends Component $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); - $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]); + $this->resource->additional_networks()->syncWithoutDetaching([ + $network->id => ['server_id' => $server->id], + ]); $this->dispatch('refresh'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index ea8394c1b1..47080cd86b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -818,19 +818,21 @@ class All extends Component { $isMember = auth()->user()?->isMember(); - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } + return $variables + ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) + ->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } - return "$item->key=$item->value"; - })->join("\n"); + return "$item->key=$item->value"; + })->join("\n"); } public function switch() @@ -908,8 +910,7 @@ class All extends Component $deletedCount = $this->deleteRemovedVariables(false, $variables); if ($deletedCount > 0) { $changesMade = true; - } elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedCount < 0) { $errorOccurred = true; } @@ -926,8 +927,7 @@ class All extends Component $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); if ($deletedPreviewCount > 0) { $changesMade = true; - } elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedPreviewCount < 0) { $errorOccurred = true; } @@ -988,6 +988,12 @@ class All extends Component // Get all environment variables that will be deleted $variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get(); + // Generated Compose variables are managed by Coolify and must survive a bulk + // replacement even when they are omitted from the pasted environment file. + $variablesToDelete = $variablesToDelete->reject( + fn (EnvironmentVariable $environmentVariable): bool => $this->isProtectedEnvironmentVariable($environmentVariable->key) + ); + // If there are no variables to delete, return 0 if ($variablesToDelete->isEmpty()) { return 0; @@ -1001,13 +1007,13 @@ class All extends Component if ($isUsed) { $this->dispatch('error', "Cannot delete environment variable '{$envVar->key}'

Please remove it from the Docker Compose file first."); - return 0; + return -1; } } } // If we get here, no variables are used in Docker Compose, so we can delete them - $this->resource->$method()->whereNotIn('key', array_keys($variables))->delete(); + $this->resource->$method()->whereKey($variablesToDelete->modelKeys())->delete(); return $variablesToDelete->count(); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 4b68c4d8f1..e47d3818fe 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -152,6 +152,8 @@ class Show extends Component */ public function loadValues(): void { + $this->authorize('update', $this->env); + if ($this->valuesLoaded) { return; } @@ -185,7 +187,8 @@ class Show extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void + { if ($toModel) { $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); @@ -320,6 +323,7 @@ class Show extends Component $this->syncData(true); $this->syncData(false); $this->dispatch('success', 'Environment variable updated.'); + $this->dispatch('environment-variable-updated', envId: $this->env->id); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index aa26071020..e8202b8547 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -151,13 +151,18 @@ class ExecuteContainerCommand extends Component }); if ($this->containers->count() === 1) { - $this->selected_container = data_get($this->containers->first(), 'container.Names'); + $this->selected_container = $this->containerTarget($this->containers->first()); $this->connectToContainer(); } $this->containersLoaded = true; } + private function containerTarget(array $container): string + { + return data_get($container, 'server.uuid').':'.data_get($container, 'container.Names'); + } + public function updatedSelectedContainer() { if ($this->selected_container !== 'default') { @@ -202,12 +207,12 @@ class ExecuteContainerCommand extends Component try { $this->authorize('canAccessTerminal'); // Validate container name format - if (! ValidationPatterns::isValidContainerName($this->selected_container)) { + if (! ValidationPatterns::isValidContainerName(str($this->selected_container)->after(':')->value())) { throw new \InvalidArgumentException('Invalid container name format'); } // Verify container exists in our allowed list - $container = collect($this->containers)->firstWhere('container.Names', $this->selected_container); + $container = $this->containers->first(fn ($candidate) => $this->containerTarget($candidate) === $this->selected_container); if (is_null($container)) { throw new \RuntimeException('Container not found.'); } diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 67a040ef77..3da0c75876 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -17,12 +17,15 @@ 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\Process; use Livewire\Attributes\Locked; use Livewire\Component; class GetLogs extends Component { + use AuthorizesRequests; + public const MAX_LOG_LINES = 50000; public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; @@ -82,6 +85,10 @@ class GetLogs extends Component public function instantSave() { if (! is_null($this->resource)) { + if (auth()->user()->cannot('update', $this->resource)) { + return; + } + if ($this->resource->getMorphClass() === Application::class) { $this->resource->settings->is_include_timestamps = $this->showTimeStamps; $this->resource->settings->save(); @@ -124,6 +131,12 @@ class GetLogs extends Component $this->streamLogs = ! $this->streamLogs; } + public function showAllLogs(): void + { + $this->numberOfLines = -1; + $this->getLogs(true); + } + public function getLogs($refresh = false) { if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { @@ -142,22 +155,25 @@ class GetLogs extends Component if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) { return; } - if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { + $logTail = $this->numberOfLines === -1 ? 'all' : $this->numberOfLines; + if ($logTail !== 'all' && ($logTail <= 0 || is_null($logTail))) { $this->numberOfLines = 1000; + $logTail = $this->numberOfLines; } - if ($this->numberOfLines > self::MAX_LOG_LINES) { + if ($logTail !== 'all' && $logTail > self::MAX_LOG_LINES) { $this->numberOfLines = self::MAX_LOG_LINES; + $logTail = $this->numberOfLines; } if ($this->container) { if ($this->showTimeStamps) { if ($this->server->isSwarm()) { - $command = "docker service logs -n {$this->numberOfLines} -t {$this->container}"; + $command = "docker service logs -n {$logTail} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } } else { - $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; + $command = "docker logs -n {$logTail} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; @@ -165,13 +181,13 @@ class GetLogs extends Component } } else { if ($this->server->isSwarm()) { - $command = "docker service logs -n {$this->numberOfLines} {$this->container}"; + $command = "docker service logs -n {$logTail} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } } else { - $command = "docker logs -n {$this->numberOfLines} {$this->container}"; + $command = "docker logs -n {$logTail} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 6a128a1426..70633fe030 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -86,7 +86,7 @@ class HealthChecks extends Component } } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 61bc6b0fbc..717007bc04 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -102,7 +102,7 @@ class Add extends Component } } - public function saveScheduledTask() + private function saveScheduledTask(): void { try { $task = new ScheduledTask; @@ -128,7 +128,7 @@ class Add extends Component $this->dispatch('refreshTasks'); $this->dispatch('success', 'Scheduled task added.'); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 14777724e5..c121f1b93b 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -87,7 +87,7 @@ class Show extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -169,9 +169,9 @@ class Show extends Component $this->task->delete(); if ($this->type === 'application') { - return redirect()->route('project.application.scheduled-tasks.show', $this->parameters); + return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters); } else { - return redirect()->route('project.service.scheduled-tasks.show', $this->parameters); + return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters); } } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index efe54a6a7d..3dadfb46f4 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\Storages; +use App\Livewire\Project\Service\Storage as StorageComponent; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -44,7 +45,7 @@ class All extends Component public bool $deleteDockerVolume = false; - protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; + protected $listeners = ['refreshVolumeList' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; public function mount(): void { @@ -182,7 +183,7 @@ class All extends Component $storage->delete(); $this->refreshList(); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(StorageComponent::class); $this->dispatch('configurationChanged'); return true; diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php deleted file mode 100644 index 7e1e2dec1d..0000000000 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ /dev/null @@ -1,200 +0,0 @@ - 'name', - 'mountPath' => 'mount', - 'hostPath' => 'host', - ]; - - protected function rules(): array - { - return [ - 'name' => ValidationPatterns::volumeNameRules(), - 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], - 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], - 'isPreviewSuffixEnabled' => 'required|boolean', - ]; - } - - protected function messages(): array - { - return array_merge( - ValidationPatterns::volumeNameMessages(), - [ - 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', - 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.', - ] - ); - } - - /** - * Sync data between component properties and model - * - * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. - */ - private function syncData(bool $toModel = false): void - { - if ($toModel) { - // Sync TO model (before save) - $this->storage->name = $this->name; - $this->storage->mount_path = $this->mountPath; - $this->storage->host_path = $this->hostPath; - $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; - } else { - // Sync FROM model (on load/refresh) - $this->name = $this->storage->name; - $this->mountPath = $this->storage->mount_path; - $this->hostPath = $this->storage->host_path; - $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true; - } - } - - public function mount(): void - { - $this->syncData(false); - $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); - // PR deployment volume suffixes only apply to git-based applications. - $this->supportsPreviewSuffix = $this->resource instanceof Application - && $this->resource->git_based() - && filled($this->resource->git_repository) - && ! $this->isService; - // Parent All batches badge/url; isolated embeds still hydrate themselves. - if (! $this->backupMetaHydrated) { - $this->refreshBackupStatus(); - } - } - - #[On('refreshVolumeBackups')] - public function refreshBackupStatus(): void - { - $backup = $this->storage->scheduledBackups()->first(); - - $this->hasEnabledBackup = $backup?->enabled ?? false; - $this->backupUrl = null; - - if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) { - return; - } - - $this->resource->loadMissing('environment.project'); - - $parameters = [ - 'project_uuid' => $this->resource->project()->uuid, - 'environment_uuid' => $this->resource->environment->uuid, - 'application_uuid' => $this->resource->uuid, - ]; - $hasOtherBackups = ScheduledVolumeBackup::query() - ->forApplication($this->resource) - ->where('id', '!=', $backup->id) - ->exists(); - - $this->backupUrl = $hasOtherBackups - ? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name]) - : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); - } - - public function openBackupModal(): void - { - $this->authorize('update', $this->resource); - $this->showBackupModal = true; - } - - #[On('modalClosed')] - public function onModalClosed(): void - { - // Drop the nested Create component from the DOM after close to free snapshot weight. - if ($this->showBackupModal) { - $this->showBackupModal = false; - } - } - - public function instantSave(): void - { - $this->authorize('update', $this->resource); - $this->validate(); - - $this->syncData(true); - $this->storage->save(); - $this->dispatch('success', 'Storage updated successfully'); - } - - public function submit() - { - $this->authorize('update', $this->resource); - - $this->validate(); - $this->syncData(true); - $this->storage->save(); - $this->dispatch('success', 'Storage updated successfully'); - } - - public function delete($password, $selectedActions = []) - { - $this->authorize('update', $this->resource); - - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; - } - - if ($this->storage->scheduledBackups()->exists()) { - $this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.'); - - return false; - } - - $this->storage->delete(); - $this->dispatch('refreshStorages'); - $this->dispatch('configurationChanged'); - - return true; - } -} diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index ef7b36ff72..b4ae3ead3a 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -69,6 +69,10 @@ class VolumeBackups extends Component public bool $delete_backup_s3 = false; + public bool $delete_associated_backups_locally = false; + + public bool $delete_associated_backups_s3 = false; + public Collection $availableS3Storages; protected function rules(): array @@ -147,7 +151,11 @@ class VolumeBackups extends Component } $this->resetErrorBag('s3StorageId'); - $this->backup?->update(['s3_storage_id' => $this->s3StorageId]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', 'S3 storage updated.'); } @@ -163,11 +171,11 @@ class VolumeBackups extends Component $this->saveToS3 = ! $this->saveToS3; $this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup; - $this->backup?->update([ - 'save_s3' => $this->saveToS3, - 'disable_local_backup' => $this->disableLocalBackup, - 's3_storage_id' => $this->s3StorageId, - ]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.'); } @@ -228,14 +236,18 @@ class VolumeBackups extends Component } try { - DeleteScheduledVolumeBackup::run($this->backup); + DeleteScheduledVolumeBackup::run( + $this->backup, + deleteLocalArchives: in_array('delete_associated_backups_locally', $selectedActions, true), + deleteS3Archives: in_array('delete_associated_backups_s3', $selectedActions, true), + ); $this->backup = null; - $this->dispatch('success', 'Storage backup schedule and archives deleted.'); + $this->dispatch('success', 'Storage backup schedule deleted.'); $this->redirectRoute($this->routeName('index'), $this->routeParameters(includeBackup: false), navigate: true); return true; } catch (Throwable $exception) { - $this->dispatch('error', 'Could not delete the backup archives: '.$exception->getMessage()); + $this->dispatch('error', 'Could not delete the backup schedule: '.$exception->getMessage()); return false; } @@ -336,6 +348,10 @@ class VolumeBackups extends Component return view('livewire.project.shared.storages.volume-backups', [ 'executions' => $executions ?? collect(), 'latestExecution' => $this->backup?->executions()->first(), + 'deleteScheduleCheckboxes' => [ + ['id' => 'delete_associated_backups_locally', 'label' => 'Delete all local archives created by this schedule.'], + ['id' => 'delete_associated_backups_s3', 'label' => 'Delete all S3 archives created by this schedule.'], + ], ]); } diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index b6d448e903..0d26d1d669 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -28,6 +28,8 @@ class CloudInitScripts extends Component public function loadScripts() { + $this->authorize('viewAny', CloudInitScript::class); + CloudInitScript::ownedByCurrentTeam() ->whereNull('uuid') ->get() diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index ba2655b434..2c31d22035 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -94,6 +94,7 @@ class CloudProviderTokenForm extends Component public function addToken() { + $this->authorize('create', CloudProviderToken::class); $this->validate(); try { diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 2a33591822..d10ebb1c14 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -3,8 +3,10 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; +use App\Services\Dns\CloudflareDnsProvider; use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class IntegrationTokenEditor extends Component @@ -21,6 +23,13 @@ class IntegrationTokenEditor extends Component public array $metadata = []; + public int $zoneCount = 0; + + /** @var array */ + public array $zones = []; + + public bool $automaticDns = true; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -32,6 +41,8 @@ class IntegrationTokenEditor extends Component $this->name = $this->integrationToken->name; $this->capabilities = $this->integrationToken->capabilities; $this->metadata = $this->integrationToken->metadata ?? []; + $this->loadZones(); + $this->automaticDns = $this->integrationToken->automaticDnsEnabled(); } protected function rules(): array @@ -43,6 +54,7 @@ class IntegrationTokenEditor extends Component 'newToken' => ['nullable', 'string'], 'capabilities' => ['required', 'array', 'min:1'], 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'automaticDns' => ['boolean'], ]; if ($this->integrationToken->provider === 'infisical') { @@ -66,13 +78,20 @@ class IntegrationTokenEditor extends Component ]; } - public function save(IntegrationTokenValidator $validator): void + public function save(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void { $this->authorize('update', $this->integrationToken); $validated = $this->validate(); $provider = $this->integrationToken->provider; $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); + if ($provider === 'cloudflare') { + if ($validated['automaticDns']) { + unset($metadata['automatic_dns']); + } else { + $metadata['automatic_dns'] = false; + } + } $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() !== collect($this->integrationToken->capabilities)->sort()->values()->all(); $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []); @@ -95,8 +114,14 @@ class IntegrationTokenEditor extends Component $updates['token'] = $validated['newToken']; } - $this->integrationToken->update($updates); + DB::transaction(function () use ($updates, $provider, $validated, $capabilitiesChanged, $cloudflare): void { + $this->integrationToken->update($updates); + if ($provider === 'cloudflare' && (filled($validated['newToken']) || $capabilitiesChanged)) { + $cloudflare->syncZones($this->integrationToken); + } + }); $this->newToken = ''; + $this->loadZones(); auditLog('ui.integration_token.updated', [ 'team_id' => currentTeam()->id, @@ -128,6 +153,12 @@ class IntegrationTokenEditor extends Component return; } + if ($this->integrationToken->managedDnsRecords()->exists()) { + $this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.'); + + return; + } + $uuid = $this->integrationToken->uuid; $name = $this->integrationToken->name; $provider = $this->integrationToken->provider; @@ -145,8 +176,38 @@ class IntegrationTokenEditor extends Component $this->dispatch('success', 'Integration token deleted successfully.'); } + public function refreshZones(CloudflareDnsProvider $cloudflare): void + { + $this->authorize('update', $this->integrationToken); + try { + $cloudflare->syncZones($this->integrationToken); + $this->integrationToken->refresh(); + $this->loadZones(); + $this->dispatch('success', "Cloudflare zones refreshed. {$this->zoneCount} accessible zones found."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function render() { return view('livewire.security.integration-token-editor'); } + + private function loadZones(): void + { + $this->zones = $this->integrationToken->dnsZones() + ->select(['id', 'integration_token_id', 'name', 'account_name']) + ->withCount('managedRecords') + ->orderBy('name') + ->get() + ->map(fn ($zone) => [ + 'id' => $zone->id, + 'name' => $zone->name, + 'account_name' => $zone->account_name, + 'managed_records_count' => $zone->managed_records_count, + ]) + ->all(); + $this->zoneCount = count($this->zones); + } } diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 26ecce1e14..482e17bc37 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -3,8 +3,10 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; +use App\Services\Dns\CloudflareDnsProvider; use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class IntegrationTokenForm extends Component @@ -23,6 +25,8 @@ class IntegrationTokenForm extends Component public array $metadata = []; + public bool $automaticDns = true; + public function mount(): void { $this->authorize('create', IntegrationToken::class); @@ -33,6 +37,7 @@ class IntegrationTokenForm extends Component if ($this->provider === 'cloudflare') { $this->capabilities = ['dns']; $this->metadata = []; + $this->automaticDns = true; } else { $this->capabilities = ['secrets']; $this->metadata = $this->provider === 'infisical' @@ -51,6 +56,7 @@ class IntegrationTokenForm extends Component 'token' => ['required', 'string'], 'capabilities' => ['required', 'array', 'min:1'], 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'automaticDns' => ['boolean'], ]; if ($this->provider === 'infisical') { @@ -79,10 +85,13 @@ class IntegrationTokenForm extends Component ]; } - public function addToken(IntegrationTokenValidator $validator): void + public function addToken(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void { $validated = $this->validate(); $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); + if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) { + $metadata['automatic_dns'] = false; + } try { if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { @@ -91,14 +100,17 @@ class IntegrationTokenForm extends Component return; } - $integrationToken = IntegrationToken::query()->create([ - 'provider' => $validated['provider'], - 'name' => $validated['name'], - 'token' => $validated['token'], - 'capabilities' => $validated['capabilities'], - 'metadata' => $metadata ?: null, - 'team_id' => currentTeam()->id, - ]); + $integrationToken = DB::transaction(function () use ($validated, $metadata, $cloudflare): IntegrationToken { + $token = IntegrationToken::query()->create([ + 'provider' => $validated['provider'], 'name' => $validated['name'], 'token' => $validated['token'], + 'capabilities' => $validated['capabilities'], 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id, + ]); + if ($token->provider === 'cloudflare') { + $cloudflare->syncZones($token); + } + + return $token; + }); auditLog('ui.integration_token.created', [ 'team_id' => currentTeam()->id, diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 71805fb841..34b2b38a07 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -22,7 +22,7 @@ class IntegrationTokens extends Component #[On('integrationTokenAdded')] public function loadTokens(): void { - $this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get(); + $this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get(); } public function deleteToken(int $tokenId, string $password = ''): void @@ -36,6 +36,12 @@ class IntegrationTokens extends Component return; } + if ($token->managedDnsRecords()->exists()) { + $this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.'); + + return; + } + $tokenUuid = $token->uuid; $tokenName = $token->name; $provider = $token->provider; diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 8b170e6ae0..9a7ff4e979 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,13 +10,38 @@ class Index extends Component { use AuthorizesRequests; + public ?string $selectedPrivateKeyUuid = null; + public function getListeners(): array { return [ 'securityResourceChanged' => '$refresh', + 'privateKeyCreated' => 'refreshResources', + 'privateKeyDeleted' => 'refreshResources', + 'privateKeyUpdated' => 'refreshResources', + 'modalClosed' => 'closeEditor', ]; } + public function openEditor(string $privateKeyUuid): void + { + $privateKey = PrivateKey::ownedByCurrentTeam()->whereUuid($privateKeyUuid)->firstOrFail(); + $this->authorize('view', $privateKey); + + $this->selectedPrivateKeyUuid = $privateKey->uuid; + } + + public function closeEditor(): void + { + $this->selectedPrivateKeyUuid = null; + } + + public function refreshResources(): void + { + $this->closeEditor(); + $this->dispatch('close-modal'); + } + public function generatePrivateKey(string $type) { try { diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 7fa2300031..1b8f26ff28 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -76,7 +76,9 @@ class Show extends Component // Sync FROM model (on load/refresh) $this->name = $this->private_key->name; $this->description = $this->private_key->description; - $this->privateKeyValue = $this->private_key->private_key; + $this->privateKeyValue = auth()->user()->can('update', $this->private_key) + ? $this->private_key->private_key + : ''; $this->isGitRelated = $this->private_key->is_git_related; } } @@ -92,6 +94,7 @@ class Show extends Component $this->syncData(false); $this->isInUse = $this->private_key->isInUse(); + $this->public_key = $this->private_key->getPublicKey(); } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { @@ -99,14 +102,6 @@ class Show extends Component } } - public function loadPublicKey() - { - $this->public_key = $this->private_key->getPublicKey(); - if ($this->public_key === 'Error loading private key') { - $this->dispatch('error', 'Failed to load public key. The private key may be invalid.'); - } - } - public function delete() { try { @@ -123,8 +118,7 @@ class Show extends Component currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); if ($this->modalMode) { - $this->dispatch('securityResourceChanged'); - $this->dispatch('close-modal'); + $this->dispatch('privateKeyDeleted'); return null; } @@ -150,10 +144,12 @@ class Show extends Component ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); - $this->dispatch('securityResourceChanged'); if ($this->modalMode) { - $this->dispatch('close-modal'); + $this->dispatch('privateKeyUpdated'); + + return null; } + $this->dispatch('securityResourceChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/SelectTeam.php b/app/Livewire/SelectTeam.php new file mode 100644 index 0000000000..d0a9328562 --- /dev/null +++ b/app/Livewire/SelectTeam.php @@ -0,0 +1,50 @@ +user(); + + // A team is already active, or the user has at most one team: nothing to pick. + if ($user->currentTeam() || $user->teams->count() <= 1) { + $resolved = $user->resolveStoredTeam(); + if ($resolved) { + refreshSession($resolved); + } + + return redirect()->route('dashboard'); + } + } + + public function selectTeam(int $teamId) + { + $user = auth()->user(); + if (! $user->teams->contains('id', $teamId)) { + return; + } + $team = Team::find($teamId); + if (! $team) { + return; + } + refreshSession($team); + + return redirect()->route('dashboard'); + } + + public function render(): View + { + return view('livewire.select-team', [ + 'teams' => auth()->user()->teams, + ])->layout('layouts.simple'); + } +} diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index a94881b12b..895ce34e79 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -42,10 +42,9 @@ class Advanced extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->concurrent_builds = $this->concurrentBuilds; $this->server->settings->dynamic_timeout = $this->dynamicTimeout; @@ -67,6 +66,7 @@ class Advanced extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { @@ -81,6 +81,7 @@ class Advanced extends Component $this->serverDiskUsageCheckFrequency = $this->server->settings->getOriginal('server_disk_usage_check_frequency'); throw new \Exception('Invalid Cron / Human expression for Disk Usage Check Frequency.'); } + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Analytics/Show.php b/app/Livewire/Server/Analytics/Show.php new file mode 100644 index 0000000000..abcf904e0c --- /dev/null +++ b/app/Livewire/Server/Analytics/Show.php @@ -0,0 +1,26 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('view', $this->server); + } + + public function render(): View + { + return view('livewire.server.analytics.show'); + } +} diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 1cda771a7c..567034c801 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Validate; use Livewire\Component; class Charts extends Component @@ -23,15 +24,44 @@ class Charts extends Component public bool $poll = true; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $sentinelMetricsRefreshRateSeconds; + + #[Validate(['required', 'integer', 'min:1'])] + public int|string $sentinelMetricsHistoryDays; + + #[Validate(['required', 'integer', 'min:10'])] + public int|string $sentinelPushIntervalSeconds; + public function mount(string $server_uuid) { try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds; + $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; + $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; } catch (\Throwable $e) { return handleError($e, $this); } } + public function saveMetricsSettings(): void + { + try { + $this->authorize('update', $this->server); + $this->validate(); + + $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds; + $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; + $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; + $this->server->settings->save(); + + $this->dispatch('success', 'Metrics settings updated. Restarting Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function toggleMetrics(): void { try { @@ -70,11 +100,9 @@ class Charts extends Component try { $cpuMetrics = $this->server->getCpuMetrics($this->interval); $memoryMetrics = $this->server->getMemoryMetrics($this->interval); - $this->dispatch("refreshChartData-{$this->chartId}-cpu", [ - 'seriesData' => $cpuMetrics, - ]); - $this->dispatch("refreshChartData-{$this->chartId}-memory", [ - 'seriesData' => $memoryMetrics, + $this->dispatch("refreshChartData-{$this->chartId}-metrics", [ + 'cpuSeries' => $cpuMetrics, + 'memorySeries' => $memoryMetrics, ]); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 24acdecad1..d0a8d8ca9d 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -97,10 +97,9 @@ class DockerCleanup extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->force_docker_cleanup = $this->forceDockerCleanup; $this->server->settings->docker_cleanup_frequency = $this->dockerCleanupFrequency; @@ -122,6 +121,7 @@ class DockerCleanup extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { @@ -154,6 +154,7 @@ class DockerCleanup extends Component $this->dockerCleanupFrequency = $this->server->settings->getOriginal('docker_cleanup_frequency'); throw new \Exception('Invalid Cron / Human expression for Docker Cleanup Frequency.'); } + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index ae53488bd5..c3f36f7a7e 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -52,7 +52,7 @@ class LogDrains extends Component } } - public function syncDataNewRelic(bool $toModel = false) + private function syncDataNewRelic(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_newrelic_enabled = $this->isLogDrainNewRelicEnabled; @@ -65,7 +65,7 @@ class LogDrains extends Component } } - public function syncDataAxiom(bool $toModel = false) + private function syncDataAxiom(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled; @@ -78,7 +78,7 @@ class LogDrains extends Component } } - public function syncDataCustom(bool $toModel = false) + private function syncDataCustom(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_custom_enabled = $this->isLogDrainCustomEnabled; @@ -91,7 +91,7 @@ class LogDrains extends Component } } - public function syncData(bool $toModel = false, ?string $type = null) + private function syncData(bool $toModel = false, ?string $type = null): void { if ($toModel) { $this->customValidation(); @@ -106,6 +106,7 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); } + $this->auditLogDrain('updated'); $this->server->settings->save(); } else { if ($type === 'newrelic') { @@ -119,6 +120,7 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); } + $this->auditLogDrain($this->{$enabledProperty} ? 'enabled' : 'disabled', $type); } } @@ -165,6 +167,7 @@ class LogDrains extends Component try { $this->authorize('update', $this->server); $this->syncData(true); + $this->auditLogDrain('updated'); if ($this->server->isLogDrainEnabled()) { StartLogDrain::run($this->server); $this->dispatch('success', 'Log drain service started.'); @@ -246,6 +249,16 @@ class LogDrains extends Component }; } + private function auditLogDrain(string $action, ?string $type = null): void + { + auditLog("ui.server.log_drain.{$action}", [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'provider' => $type, + ]); + } + private function validateLogDrainSettings(string $type): void { match ($type) { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 242b0971ec..b64adf10a3 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -10,6 +10,7 @@ use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Carbon; use Livewire\Component; class Navbar extends Component @@ -34,14 +35,18 @@ class Navbar extends Component public array $serverSwitcherOptions = []; + public ?bool $sentinelWarningOverride = null; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; return [ 'refreshServerShow' => 'refreshServer', + 'sentinel-restart-requested' => 'hideSentinelWarning', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', "echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus', + "echo-private:team.{$teamId},SentinelSynchronized" => 'refreshSentinelStatus', ]; } @@ -263,6 +268,31 @@ class Navbar extends Component } $this->refreshServer(); + $this->sentinelWarningOverride = null; + $sentinelStatus = $this->server->sentinelStatus(); + $sentinelStatusStartedAt = $this->server->sentinel_waiting_since ?? Carbon::parse($this->server->sentinel_updated_at); + $sentinelTimeoutSeconds = $this->server->sentinel_waiting_since !== null + ? $this->server->firstSentinelReportTimeoutSeconds() + : $this->server->waitBeforeDoingSshCheck(); + $expiresInMilliseconds = max( + 0, + ($sentinelStatusStartedAt->copy()->addSeconds($sentinelTimeoutSeconds)->timestamp - now()->timestamp) * 1000 + ); + $this->dispatch( + 'sentinel-status-changed', + outOfSync: $this->server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync', + expiresInMilliseconds: $expiresInMilliseconds, + ); + } + + public function hideSentinelWarning(): void + { + $this->sentinelWarningOverride = false; + } + + public function refreshAgentStatus(): void + { + $this->refreshSentinelStatus(); } /** diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index a85306f6bf..1c92078f27 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Enums\ServerRole; use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; @@ -40,7 +41,7 @@ class ByIp extends Component public int $port = 22; - public bool $is_build_server = false; + public string $server_role = ServerRole::BOTH->value; public function mount() { @@ -60,7 +61,7 @@ class ByIp extends Component 'ip' => ['required', 'string', new ValidServerIp], 'user' => ValidationPatterns::serverUsernameRules(), 'port' => 'required|integer|between:1,65535', - 'is_build_server' => 'required|boolean', + 'server_role' => ['required', 'in:deployment,build,both'], ]; } @@ -80,8 +81,8 @@ class ByIp extends Component 'port.required' => 'The Port field is required.', 'port.integer' => 'The Port field must be an integer.', 'port.between' => 'The Port field must be between 1 and 65535.', - 'is_build_server.required' => 'The Build Server field is required.', - 'is_build_server.boolean' => 'The Build Server field must be true or false.', + 'server_role.required' => 'The Server Role field is required.', + 'server_role.in' => 'The selected Server Role is invalid.', ]); } @@ -164,14 +165,15 @@ class ByIp extends Component 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, ]; - if ($this->is_build_server) { + if ($this->server_role === ServerRole::BUILD->value) { data_forget($payload, 'proxy'); } $server = Server::create($payload); $server->proxy->set('status', 'exited'); $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); - $server->settings->is_build_server = $this->is_build_server; + $server->settings->server_role = ServerRole::from($this->server_role); + $server->settings->is_build_server = $this->server_role === ServerRole::BUILD->value; $server->settings->save(); return redirectRoute($this, 'server.show', [$server->uuid]); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 811a01eb19..0454d97049 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -56,7 +56,7 @@ class Proxy extends Component $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->syncData(false); - $this->loadProxyConfiguration(); + $this->clearAppliedTraefikBranchWarning(); } private function syncData(bool $toModel = false): void @@ -105,6 +105,8 @@ class Proxy extends Component try { $this->authorize('update', $this->server); $this->server->proxy = null; + $this->server->detected_traefik_version = null; + $this->server->traefik_outdated_info = null; $this->server->save(); $this->dispatch('reloadWindow'); @@ -276,6 +278,8 @@ class Proxy extends Component return null; } + $configuredBranch = $this->getConfiguredTraefikBranch(); + // Check if we have outdated info stored for this server (faster than computing) $outdatedInfo = $this->server->traefik_outdated_info; $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v'); @@ -283,9 +287,15 @@ class Proxy extends Component if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') { // Use the upgrade_target field if available (e.g., "v3.6") if (isset($outdatedInfo['upgrade_target'])) { - return str_starts_with($outdatedInfo['upgrade_target'], 'v') + $upgradeTarget = str_starts_with($outdatedInfo['upgrade_target'], 'v') ? $outdatedInfo['upgrade_target'] : "v{$outdatedInfo['upgrade_target']}"; + + if ($configuredBranch && version_compare($configuredBranch, ltrim($upgradeTarget, 'v'), '>=')) { + return null; + } + + return $upgradeTarget; } } @@ -315,9 +325,53 @@ class Proxy extends Component } } - return $newestBranch ? "v{$newestBranch}" : null; + if (! $newestBranch || ($configuredBranch && version_compare($configuredBranch, $newestBranch, '>='))) { + return null; + } + + return "v{$newestBranch}"; } catch (\Throwable $e) { return null; } } + + private function getConfiguredTraefikBranch(): ?string + { + if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) { + return null; + } + + if (! is_string($this->proxySettings)) { + return null; + } + + if (! preg_match('/^\s*image:\s*[\'\"]?traefik:v?(\d+\.\d+)(?:\.\d+)?[\'\"]?\s*$/mi', $this->proxySettings, $matches)) { + return null; + } + + return $matches[1]; + } + + private function clearAppliedTraefikBranchWarning(): void + { + $outdatedInfo = $this->server->traefik_outdated_info; + + if (data_get($outdatedInfo, 'type') !== 'minor_upgrade') { + return; + } + + $configuredBranch = $this->getConfiguredTraefikBranch(); + $upgradeTarget = ltrim((string) data_get($outdatedInfo, 'upgrade_target'), 'v'); + + if (! $configuredBranch || ! $upgradeTarget || version_compare($configuredBranch, $upgradeTarget, '<')) { + return; + } + + Server::query() + ->whereKey($this->server->id) + ->where('traefik_outdated_info->type', data_get($outdatedInfo, 'type')) + ->where('traefik_outdated_info->current', data_get($outdatedInfo, 'current')) + ->where('traefik_outdated_info->upgrade_target', data_get($outdatedInfo, 'upgrade_target')) + ->update(['traefik_outdated_info' => null]); + } } diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 9ea87161d8..5d0d2538bd 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -5,11 +5,29 @@ namespace App\Livewire\Server; use App\Models\Server; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Pagination\LengthAwarePaginator; use Livewire\Component; +use Livewire\WithPagination; class Resources extends Component { use AuthorizesRequests; + use WithPagination; + + public int $perPage = 10; + + public string $search = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage(); + } public ?Server $server = null; @@ -93,6 +111,10 @@ class Resources extends Component public function loadManagedContainers() { try { + if ($this->activeTab !== 'managed') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'managed'; $this->server->refresh(); } catch (\Throwable $e) { @@ -102,6 +124,10 @@ class Resources extends Component public function loadUnmanagedContainers() { + if ($this->activeTab !== 'unmanaged') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'unmanaged'; try { $this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray(); @@ -125,6 +151,29 @@ class Resources extends Component public function render() { - return view('livewire.server.resources'); + $resources = $this->activeTab === 'managed' + ? $this->server->definedResources()->sortBy('name', SORT_NATURAL) + : collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL); + $search = trim($this->search); + if ($search !== '') { + $nameKey = $this->activeTab === 'managed' ? 'name' : 'Names'; + $resources = $resources->filter(fn ($resource) => str((string) data_get($resource, $nameKey)) + ->contains($search, ignoreCase: true)); + } + $this->perPage = max(1, min(100, $this->perPage)); + $lastPage = max(1, (int) ceil($resources->count() / $this->perPage)); + $page = max(1, min((int) $this->getPage(), $lastPage)); + if ($page !== $this->getPage()) { + $this->setPage($page); + } + + return view('livewire.server.resources', [ + 'resources' => new LengthAwarePaginator( + $resources->forPage($page, $this->perPage)->values(), + $resources->count(), + $this->perPage, + $page, + ), + ]); } } diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index b4b99a3e7c..999482dcff 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -62,10 +62,9 @@ class TerminalAccess extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); // No other fields to sync for terminal access } else { diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a69eb3f807..0ad55a9deb 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -2,8 +2,6 @@ namespace App\Livewire\Server; -use App\Actions\Server\StartSentinel; -use App\Actions\Server\StopSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; @@ -22,20 +20,13 @@ class Sentinel extends Component public ?string $sentinelUpdatedAt = null; - #[Validate(['required', 'integer', 'min:1'])] - public int|string $sentinelMetricsRefreshRateSeconds; + public string $sentinelStatus = 'out_of_sync'; - #[Validate(['required', 'integer', 'min:1'])] - public int|string $sentinelMetricsHistoryDays; - - #[Validate(['required', 'integer', 'min:10'])] - public int|string $sentinelPushIntervalSeconds; + public ?int $sentinelRestartRequestedAt = null; #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; - public bool $isSentinelEnabled; - public bool $isSentinelDebugEnabled; public ?string $sentinelCustomDockerImage = null; @@ -46,6 +37,7 @@ class Sentinel extends Component return [ "echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted', + "echo-private:team.{$teamId},SentinelSynchronized" => 'handleSentinelSynchronized', ]; } @@ -54,30 +46,22 @@ class Sentinel extends Component $this->syncData(); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; - $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds; - $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; - $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; $this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl; - $this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; $this->server->settings->save(); } else { $this->isMetricsEnabled = $this->server->settings->is_metrics_enabled; $this->sentinelToken = $this->server->settings->sentinel_token; - $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds; - $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; - $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; + $this->sentinelStatus = $this->server->sentinelStatus(); } } @@ -88,64 +72,102 @@ class Sentinel extends Component // 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->sentinelStatus = $this->server->sentinelStatus(); + $this->sentinelRestartRequestedAt = null; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } + public function handleSentinelSynchronized($event): void + { + if ($event['serverUuid'] === $this->server->uuid) { + $this->server->refresh(); + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; + $this->sentinelStatus = 'in_sync'; + $this->sentinelRestartRequestedAt = null; + } + } + + public function refreshSentinelStatus(): void + { + if ($this->sentinelStatus === 'restarting' + && $this->sentinelRestartRequestedAt !== null + && $this->sentinelRestartRequestedAt > now()->subSeconds($this->server->firstSentinelReportTimeoutSeconds())->timestamp) { + return; + } + + $this->server->refresh(); + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; + $this->sentinelStatus = $this->server->sentinelStatus(); + } + + private function setSentinelRestarting(): void + { + $this->sentinelStatus = 'restarting'; + $this->sentinelRestartRequestedAt = now()->timestamp; + $this->dispatch( + 'sentinel-status-changed', + outOfSync: false, + expiresInMilliseconds: $this->server->firstSentinelReportTimeoutSeconds() * 1000, + ); + $this->dispatch('sentinel-restart-requested'); + } + public function restartSentinel() { try { $this->authorize('manageSentinel', $this->server); + $this->setSentinelRestarting(); $customImage = isDev() ? $this->sentinelCustomDockerImage : null; $this->server->restartSentinel($customImage); + auditLog('ui.server.sentinel.restarted', $this->auditContext()); $this->dispatch('info', 'Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); } } - public function toggleSentinel(): void - { - try { - $this->authorize('manageSentinel', $this->server); - if (! $this->isSentinelEnabled) { - if ($this->server->isBuildServer()) { - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - $customImage = isDev() ? $this->sentinelCustomDockerImage : null; - StartSentinel::run($this->server, true, null, $customImage); - $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = true; - } else { - $this->isSentinelEnabled = false; - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); - $this->dispatch('refreshServerShow'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function regenerateSentinelToken() { try { $this->authorize('manageSentinel', $this->server); + $this->setSentinelRestarting(); $this->server->settings->generateSentinelToken(); + auditLog('ui.server.sentinel.token_regenerated', $this->auditContext()); $this->dispatch('success', 'Token regenerated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); } } + public function restoreDefaultConfiguration(?string $password = null): void + { + try { + $this->authorize('manageSentinel', $this->server); + + $this->server->settings->restoreDefaultSentinelConfiguration(); + + $this->sentinelCustomDockerImage = null; + $this->syncData(); + $this->dispatch('sentinel-defaults-restored'); + $this->setSentinelRestarting(); + $this->server->restartSentinel(); + auditLog('ui.server.sentinel.defaults_restored', $this->auditContext()); + $this->dispatch('success', 'Default Sentinel configuration restored. Restarting Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function submit() { try { + $this->authorize('update', $this->server); + $this->setSentinelRestarting(); $this->syncData(true); + auditLog('ui.server.sentinel.updated', $this->auditContext([ + 'changed_fields' => ['is_metrics_enabled', 'sentinel_token', 'sentinel_custom_url', 'is_sentinel_debug_enabled'], + ])); $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -155,6 +177,7 @@ class Sentinel extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->restartSentinel(); } catch (\Throwable $e) { @@ -166,4 +189,13 @@ class Sentinel extends Component { return view('livewire.server.sentinel'); } + + private function auditContext(array $context = []): array + { + return array_merge([ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ], $context); + } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 017beb3719..3dbf7f73bd 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -2,8 +2,8 @@ namespace App\Livewire\Server; -use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; +use App\Enums\ServerRole; use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; use App\Models\Server; @@ -44,14 +44,16 @@ class Show extends Component public bool $isUsable; + #[Locked] public bool $isSwarmManager; + #[Locked] public bool $isSwarmWorker; - public bool $isBuildServer; + public string $serverRole; #[Locked] - public bool $isBuildServerLocked = false; + public ?string $pendingServerRole = null; public bool $isMetricsEnabled; @@ -67,8 +69,6 @@ class Show extends Component public ?string $sentinelCustomUrl = null; - public bool $isSentinelEnabled; - public bool $isSentinelDebugEnabled; public ?string $sentinelCustomDockerImage = null; @@ -153,7 +153,7 @@ class Show extends Component 'isUsable' => 'required', 'isSwarmManager' => 'required', 'isSwarmWorker' => 'required', - 'isBuildServer' => 'required', + 'serverRole' => ['required', 'in:deployment,build,both'], 'isMetricsEnabled' => 'required', 'sentinelToken' => 'required', 'sentinelUpdatedAt' => 'nullable', @@ -161,7 +161,6 @@ class Show extends Component 'sentinelMetricsHistoryDays' => 'required|integer|min:1', 'sentinelPushIntervalSeconds' => 'required|integer|min:10', 'sentinelCustomUrl' => 'nullable|url', - 'isSentinelEnabled' => 'required', 'isSentinelDebugEnabled' => 'required', 'serverTimezone' => 'required', ]; @@ -202,9 +201,6 @@ class Show extends Component try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->syncData(); - if (! $this->server->isBuildServer() && ! $this->server->isEmpty()) { - $this->isBuildServerLocked = true; - } // Load saved Hetzner status and validation state $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->vultrInstanceStatus = $this->server->vultr_instance_status; @@ -230,12 +226,10 @@ class Show extends Component ->toArray(); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - - $this->authorize('update', $this->server); $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) ->first(); @@ -257,17 +251,16 @@ class Show extends Component $this->server->save(); $this->server->settings->connection_timeout = $this->connectionTimeout; - $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->wildcard_domain = $this->wildcardDomain; - $this->server->settings->is_swarm_worker = $this->isSwarmWorker; - $this->server->settings->is_build_server = $this->isBuildServer; + $role = ServerRole::from($this->serverRole); + $this->server->settings->server_role = $role; + $this->server->settings->is_build_server = $role === ServerRole::BUILD; $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds; $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; $this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl; - $this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; if (! validate_timezone($this->serverTimezone)) { @@ -291,14 +284,13 @@ class Show extends Component $this->isUsable = $this->server->settings->is_usable; $this->isSwarmManager = $this->server->settings->is_swarm_manager; $this->isSwarmWorker = $this->server->settings->is_swarm_worker; - $this->isBuildServer = $this->server->settings->is_build_server; + $this->serverRole = $this->server->settings->effectiveServerRole()->value; $this->isMetricsEnabled = $this->server->settings->is_metrics_enabled; $this->sentinelToken = $this->server->settings->sentinel_token; $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds; $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->serverTimezone = $this->server->settings->server_timezone; @@ -363,6 +355,7 @@ class Show extends Component public function checkLocalhostConnection() { try { + $this->authorize('update', $this->server); $this->syncData(true); ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if ($uptime) { @@ -416,55 +409,67 @@ class Show extends Component } } - public function updatedIsBuildServer($value) + public function requestServerRoleChange(): void { try { $this->authorize('update', $this->server); - if ($value === true && ! $this->server->isEmpty()) { - $this->isBuildServer = false; - $this->dispatch('error', 'A server with existing resources cannot be configured as a build server.'); + $newRole = ServerRole::from($this->serverRole); + $currentRole = $this->server->settings()->firstOrFail()->effectiveServerRole(); + + if ($newRole === ServerRole::BUILD && ! $this->server->isEmpty()) { + $this->serverRole = $currentRole->value; + $this->dispatch('error', 'Move or remove the existing resources before you set this server to build only.'); return; } - if ($value === true && $this->isSentinelEnabled) { - $this->isSentinelEnabled = false; - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + + if ($newRole === ServerRole::DEPLOYMENT && ! Server::buildServers($this->server->team_id)->whereKeyNot($this->server->id)->exists()) { + $this->serverRole = $currentRole->value; + $this->dispatch('error', 'Add another build-capable server before you set this server to deployments only.'); + + return; } - $this->submit(); - // Dispatch event to refresh the navbar - $this->dispatch('refreshServerShow'); + + if ($newRole === ServerRole::BOTH && $currentRole !== ServerRole::BOTH) { + $this->pendingServerRole = $newRole->value; + $this->serverRole = $currentRole->value; + $this->dispatch('open-server-role-confirmation'); + + return; + } + + $this->saveServerRole($newRole); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } - public function updatedIsSentinelEnabled($value) + public function confirmServerRoleChange(): void { try { - $this->authorize('manageSentinel', $this->server); - if ($value === true) { - if ($this->isBuildServer) { - $this->isSentinelEnabled = false; - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - $customImage = isDev() ? $this->sentinelCustomDockerImage : null; - StartSentinel::run($this->server, true, null, $customImage); - } else { - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); + $this->authorize('update', $this->server); + $role = ServerRole::from($this->pendingServerRole ?? ''); + $this->pendingServerRole = null; + $this->saveServerRole($role); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } + private function saveServerRole(ServerRole $role): void + { + $this->serverRole = $role->value; + if ($role === ServerRole::BUILD && $this->server->isSentinelEnabled()) { + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + $this->server->settings->is_sentinel_enabled = false; + StopSentinel::dispatch($this->server); + $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + } + $this->submit(); + $this->dispatch('refreshServerShow'); + } + public function regenerateSentinelToken() { try { @@ -479,6 +484,7 @@ class Show extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -694,6 +700,7 @@ class Show extends Component public function submit() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server settings updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Swarm.php b/app/Livewire/Server/Swarm.php index e3e441ea0e..376705dcb7 100644 --- a/app/Livewire/Server/Swarm.php +++ b/app/Livewire/Server/Swarm.php @@ -4,6 +4,7 @@ namespace App\Livewire\Server; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class Swarm extends Component @@ -18,21 +19,27 @@ class Swarm extends Component public bool $isSwarmWorker; + #[Locked] + public bool $canUseSwarm; + public function mount(string $server_uuid) { try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->parameters = get_route_parameters(); + $this->canUseSwarm = $this->server->team->usesSwarm(); $this->syncData(); } catch (\Throwable) { return redirect()->route('server.index'); } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); + if (! $this->server->team->usesSwarm()) { + throw new \Exception('Docker Swarm is deprecated and cannot be enabled for new teams.'); + } $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; $this->server->settings->save(); @@ -45,6 +52,7 @@ class Swarm extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php new file mode 100644 index 0000000000..b0d80f00c0 --- /dev/null +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -0,0 +1,124 @@ +authorize('update', $this->server); + $this->syncData(); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->validate(); + $this->server->settings->traffic_topn = $this->trafficTopn; + $this->server->settings->traffic_sample_threshold = $this->trafficSampleThreshold; + $this->server->settings->traffic_retention_1h_days = $this->trafficRetention1hDays; + $this->server->settings->traffic_retention_1d_days = $this->trafficRetention1dDays; + $this->server->settings->is_geoip_enabled = $this->isGeoipEnabled; + $this->server->settings->geoip_refresh_days = $this->geoipRefreshDays; + $this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey; + $this->server->settings->save(); + + return; + } + + $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); + $this->trafficTopn = $this->server->settings->traffic_topn; + $this->trafficSampleThreshold = $this->server->settings->traffic_sample_threshold; + $this->trafficRetention1hDays = $this->server->settings->traffic_retention_1h_days; + $this->trafficRetention1dDays = $this->server->settings->traffic_retention_1d_days; + $this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled; + $this->geoipRefreshDays = $this->server->settings->geoip_refresh_days; + $this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key; + } + + public function toggleTrafficAnalytics(): void + { + try { + $this->authorize('update', $this->server); + if ($this->server->isSwarm() || $this->server->isBuildServer()) { + $this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.'); + + return; + } + + $enable = ! $this->server->isTrafficAnalyticsEnabled(); + ConfigureTrafficAnalytics::run($this->server, $enable); + $this->server->refresh(); + $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); + $this->dispatch('trafficAnalyticsStateChanged')->to(Analytics::class); + $this->dispatch('success', $enable + ? 'Traffic analytics enabled. Restarting proxy and Sentinel.' + : 'Traffic analytics disabled. Restarting proxy and Sentinel.'); + auditLog($enable ? 'ui.server.traffic_analytics.enabled' : 'ui.server.traffic_analytics.disabled', $this->auditContext()); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function saveTrafficAnalyticsSettings(): void + { + try { + $this->authorize('update', $this->server); + $this->syncData(true); + auditLog('ui.server.traffic_analytics.updated', $this->auditContext([ + 'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key'], + ])); + $this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.server.traffic-analytics-settings'); + } + + private function auditContext(array $context = []): array + { + return array_merge([ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ], $context); + } +} diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c39f868baf..33b77418d6 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Events\ServerValidated; +use App\Jobs\CheckAndStartSentinelJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -53,6 +54,8 @@ class ValidateAndInstall extends Component public function init(int $data = 0) { + $this->authorize('update', $this->server); + if (! $this->server->canBeValidated()) { $this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.'; $this->server->update([ @@ -160,6 +163,8 @@ class ValidateAndInstall extends Component public function validateOS() { + $this->authorize('update', $this->server); + $this->supported_os_type = $this->server->validateOS(); if (! $this->supported_os_type) { $this->error = 'Server OS type is not supported. Please install Docker manually before continuing: documentation.'; @@ -174,6 +179,8 @@ class ValidateAndInstall extends Component public function validatePrerequisites() { + $this->authorize('update', $this->server); + $validationResult = $this->server->validatePrerequisites(); $this->prerequisites_installed = $validationResult['success']; if (! $validationResult['success']) { @@ -212,6 +219,8 @@ class ValidateAndInstall extends Component public function validateDockerEngine() { + $this->authorize('update', $this->server); + $this->docker_installed = $this->server->validateDockerEngine(); $this->docker_compose_installed = $this->server->validateDockerCompose(); if (! $this->docker_installed || ! $this->docker_compose_installed) { @@ -248,6 +257,8 @@ class ValidateAndInstall extends Component public function validateDockerVersion() { + $this->authorize('update', $this->server); + if ($this->server->isSwarm()) { $swarmInstalled = $this->server->validateDockerSwarm(); if ($swarmInstalled) { @@ -265,6 +276,9 @@ class ValidateAndInstall extends Component $this->dispatch('refreshServerShow'); $this->dispatch('refreshBoardingIndex'); ServerValidated::dispatch($this->server->team_id, $this->server->uuid); + if ($this->server->isSentinelEnabled()) { + CheckAndStartSentinelJob::dispatch($this->server); + } $this->dispatch('success', 'Server validated, proxy is starting in a moment.'); $proxyShouldRun = CheckProxy::run($this->server, true); if (! $proxyShouldRun) { diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 38a2f85a73..4bb89c9f08 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -56,6 +56,8 @@ class Advanced extends Component public string $avatar_storage = 'local'; + public ?string $image_cdn_url = null; + public array $avatar_storage_options = []; public function rules() @@ -75,6 +77,7 @@ class Advanced extends Component 'webhook_allowed_internal_hosts' => 'nullable|string', 'webhook_allow_localhost' => 'boolean', 'domain_connect_private_key' => 'nullable|string', + 'image_cdn_url' => 'nullable|url|max:255', ]; } @@ -102,6 +105,7 @@ class Advanced extends Component $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id ? 's3:'.$this->settings->avatar_s3_storage_id : 'local'; + $this->image_cdn_url = $this->settings->image_cdn_url; $this->avatar_storage_options = [ ['value' => 'local', 'label' => 'Local storage'], ...S3Storage::query() @@ -216,6 +220,7 @@ class Advanced extends Component $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; + $this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null; $this->saveAvatarStorageSetting(); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php deleted file mode 100644 index 819fb39e87..0000000000 --- a/app/Livewire/Settings/ScheduledJobs.php +++ /dev/null @@ -1,391 +0,0 @@ -executions = collect(); - $this->skipLogs = collect(); - $this->managerRuns = collect(); - } - - public function mount(): void - { - if (! isInstanceAdmin()) { - redirect()->route('dashboard'); - - return; - } - - $this->loadData(); - } - - public function updatedFilterType(): void - { - $this->skipPage = 0; - $this->loadData(); - } - - public function updatedFilterDate(): void - { - $this->skipPage = 0; - $this->loadData(); - } - - public function updatedSearch(): void - { - $this->loadData(); - } - - public function updatedSortOrder(): void - { - $this->loadData(); - } - - public function skipNextPage(): void - { - $this->skipPage += $this->skipDefaultTake; - $this->showSkipPrev = true; - $this->loadData(); - } - - public function skipPreviousPage(): void - { - $this->skipPage -= $this->skipDefaultTake; - if ($this->skipPage < 0) { - $this->skipPage = 0; - } - $this->showSkipPrev = $this->skipPage > 0; - $this->loadData(); - } - - public function refresh(): void - { - $this->loadData(); - } - - public function render() - { - return view('livewire.settings.scheduled-jobs', [ - 'executions' => $this->executions, - 'skipLogs' => $this->skipLogs, - 'managerRuns' => $this->managerRuns, - ]); - } - - private function loadData(?int $teamId = null): void - { - $this->executions = $this->getExecutions($teamId); - - $parser = new SchedulerLogParser; - $allSkips = $parser->getRecentSkips(500, $teamId); - $this->skipTotalCount = $allSkips->count(); - $this->skipLogs = $this->enrichSkipLogsWithLinks( - $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values() - ); - $this->showSkipPrev = $this->skipPage > 0; - $this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount; - $this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1; - $this->managerRuns = $parser->getRecentRuns(30, $teamId); - } - - private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection - { - $taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values(); - $backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values(); - $serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values(); - - $tasks = $taskIds->isNotEmpty() - ? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id') - : collect(); - - $backups = $backupIds->isNotEmpty() - ? ScheduledDatabaseBackup::with('database') - ->whereIn('id', $backupIds) - ->get() - ->loadMorph('database', [ - ServiceDatabase::class => ['service.environment.project'], - StandaloneClickhouse::class => ['environment.project'], - StandaloneDragonfly::class => ['environment.project'], - StandaloneKeydb::class => ['environment.project'], - StandaloneMariadb::class => ['environment.project'], - StandaloneMongodb::class => ['environment.project'], - StandaloneMysql::class => ['environment.project'], - StandalonePostgresql::class => ['environment.project'], - StandaloneRedis::class => ['environment.project'], - ]) - ->keyBy('id') - : collect(); - - $servers = $serverIds->isNotEmpty() - ? Server::whereIn('id', $serverIds)->get()->keyBy('id') - : collect(); - - return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array { - $skip['link'] = null; - $skip['resource_name'] = null; - - if ($skip['type'] === 'task') { - $task = $tasks->get($skip['context']['task_id'] ?? null); - if ($task) { - $skip['resource_name'] = $skip['context']['task_name'] ?? $task->name; - $resource = $task->application ?? $task->service; - $environment = $resource?->environment; - $project = $environment?->project; - if ($project && $environment && $resource) { - $routeName = $task->application_id - ? 'project.application.scheduled-tasks' - : 'project.service.scheduled-tasks'; - $routeKey = $task->application_id ? 'application_uuid' : 'service_uuid'; - $skip['link'] = route($routeName, [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - $routeKey => $resource->uuid, - 'task_uuid' => $task->uuid, - ]); - } - } - } elseif ($skip['type'] === 'backup') { - $backup = $backups->get($skip['context']['backup_id'] ?? null); - if ($backup) { - $database = $backup->database; - $skip['resource_name'] = $database?->name ?? 'Database backup'; - - if ($database instanceof ServiceDatabase) { - $service = $database->service; - $environment = $service?->environment; - $project = $environment?->project; - if ($project && $environment && $service) { - $skip['link'] = route('project.service.database.backups', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, - ]); - } - } else { - $environment = $database?->environment; - $project = $environment?->project; - if ($project && $environment && $database) { - $skip['link'] = route('project.database.backup.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]); - } - } - } - } elseif ($skip['type'] === 'docker_cleanup') { - $server = $servers->get($skip['context']['server_id'] ?? null); - if ($server) { - $skip['resource_name'] = $server->name; - $skip['link'] = route('server.show', ['server_uuid' => $server->uuid]); - } - } - - return $skip; - }); - } - - private function getExecutions(?int $teamId = null): Collection - { - $dateFrom = $this->getDateFrom(); - - $backups = collect(); - $tasks = collect(); - $cleanups = collect(); - - if ($this->filterType === 'all' || $this->filterType === 'backup') { - $backups = $this->getBackupExecutions($dateFrom, $teamId); - } - - if ($this->filterType === 'all' || $this->filterType === 'task') { - $tasks = $this->getTaskExecutions($dateFrom, $teamId); - } - - if ($this->filterType === 'all' || $this->filterType === 'cleanup') { - $cleanups = $this->getCleanupExecutions($dateFrom, $teamId); - } - - $executions = $backups->concat($tasks)->concat($cleanups); - - if (filled($this->search)) { - $search = str($this->search)->lower()->trim()->toString(); - $executions = $executions->filter(function (array $execution) use ($search): bool { - return collect([ - $execution['type'], - $execution['resource_name'], - $execution['resource_type'], - $execution['server_name'], - $execution['message'], - ])->filter()->contains( - fn ($value): bool => str((string) $value)->lower()->contains($search) - ); - }); - } - - return ($this->sortOrder === 'oldest' - ? $executions->sortBy('created_at') - : $executions->sortByDesc('created_at')) - ->values() - ->take(100); - } - - private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection - { - $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team']) - ->where('status', 'failed') - ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) - ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId)) - ->orderBy('created_at', 'desc') - ->limit(100) - ->get(); - - return $query->map(function ($execution) { - $backup = $execution->scheduledDatabaseBackup; - $database = $backup?->database; - $server = $backup?->server(); - - return [ - 'id' => $execution->id, - 'type' => 'backup', - 'status' => $execution->status ?? 'unknown', - 'resource_name' => $database?->name ?? 'Deleted database', - 'resource_type' => $database ? class_basename($database) : null, - 'server_name' => $server?->name ?? 'Unknown', - 'server_id' => $server?->id, - 'team_id' => $backup?->team_id, - 'created_at' => $execution->created_at, - 'finished_at' => $execution->updated_at, - 'message' => $execution->message, - 'size' => $execution->size ?? null, - ]; - }); - } - - private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection - { - $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service']) - ->where('status', 'failed') - ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) - ->when($teamId, function ($q) use ($teamId) { - $q->where(function ($sub) use ($teamId) { - $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId) - ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId); - }); - }) - ->orderBy('created_at', 'desc') - ->limit(100) - ->get(); - - return $query->map(function ($execution) { - $task = $execution->scheduledTask; - $resource = $task?->application ?? $task?->service; - $server = $task?->server(); - $teamId = $server?->team_id; - - return [ - 'id' => $execution->id, - 'type' => 'task', - 'status' => $execution->status ?? 'unknown', - 'resource_name' => $task?->name ?? 'Deleted task', - 'resource_type' => $resource ? class_basename($resource) : null, - 'server_name' => $server?->name ?? 'Unknown', - 'server_id' => $server?->id, - 'team_id' => $teamId, - 'created_at' => $execution->created_at, - 'finished_at' => $execution->finished_at, - 'message' => $execution->message, - 'size' => null, - ]; - }); - } - - private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection - { - $query = DockerCleanupExecution::with(['server']) - ->where('status', 'failed') - ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) - ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId)) - ->orderBy('created_at', 'desc') - ->limit(100) - ->get(); - - return $query->map(function ($execution) { - $server = $execution->server; - - return [ - 'id' => $execution->id, - 'type' => 'cleanup', - 'status' => $execution->status ?? 'unknown', - 'resource_name' => $server?->name ?? 'Deleted server', - 'resource_type' => 'Server', - 'server_name' => $server?->name ?? 'Unknown', - 'server_id' => $server?->id, - 'team_id' => $server?->team_id, - 'created_at' => $execution->created_at, - 'finished_at' => $execution->finished_at ?? $execution->updated_at, - 'message' => $execution->message, - 'size' => null, - ]; - }); - } - - private function getDateFrom(): ?Carbon - { - return match ($this->filterDate) { - 'last_24h' => now()->subDay(), - 'last_7d' => now()->subWeek(), - 'last_30d' => now()->subMonth(), - default => null, - }; - } -} diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 1426f61f02..975ce9a241 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -74,7 +74,7 @@ class SettingsEmail extends Component $this->testEmailAddress = auth()->user()->email; } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 3b24d0cd2e..6287bd00ca 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -97,6 +97,8 @@ class SettingsOauth extends Component $this->ensureProviderCanBeEnabled($oauth); $oauth->save(); + $this->auditOauthSettings($oauth, 'updated'); + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); @@ -127,12 +129,18 @@ class SettingsOauth extends Component } $oauth->save(); + $this->auditOauthSettings($oauth, 'updated'); $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); } instanceSettings()->update([ 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, ]); + auditLog('ui.instance.authentication.updated', [ + 'team_id' => null, + 'resource' => 'instance', + 'changed_fields' => ['disable_registration_when_oauth_enabled'], + ]); if (! empty($errors)) { $this->dispatch('error', implode('
', $errors)); @@ -285,6 +293,11 @@ class SettingsOauth extends Component instanceSettings()->update([ 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, ]); + auditLog('ui.instance.authentication.updated', [ + 'team_id' => null, + 'resource' => 'instance', + 'changed_fields' => ['disable_registration_when_oauth_enabled'], + ]); $this->dispatch('success', 'Authentication settings updated successfully!'); } @@ -311,4 +324,16 @@ class SettingsOauth extends Component handleError($e, $this); } } + + private function auditOauthSettings(OauthSetting $oauth, string $action): void + { + auditLog("ui.oauth_setting.{$action}", [ + 'team_id' => null, + 'resource' => 'oauth_setting', + 'oauth_setting_name' => $oauth->provider, + 'provider' => $oauth->provider, + 'enabled' => $oauth->enabled, + 'changed_fields' => array_values(array_diff(array_keys($oauth->getChanges()), ['client_secret', 'updated_at'])), + ]); + } } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 2570c3a1b5..2dadd73661 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -122,13 +122,6 @@ class Change extends Component } } - public function boot() - { - if ($this->github_app) { - $this->github_app->makeVisible(['client_secret', 'webhook_secret']); - } - } - /** * Sync data between component properties and model * @@ -170,8 +163,9 @@ class Change extends Component $this->appId = $this->github_app->app_id; $this->installationId = $this->github_app->installation_id; $this->clientId = $this->github_app->client_id; - $this->clientSecret = $this->github_app->client_secret; - $this->webhookSecret = $this->github_app->webhook_secret; + $canUpdate = auth()->user()->can('update', $this->github_app); + $this->clientSecret = $canUpdate ? $this->github_app->client_secret : null; + $this->webhookSecret = $canUpdate ? $this->github_app->webhook_secret : null; $this->isSystemWide = $this->github_app->is_system_wide; $this->privateKeyId = $this->github_app->private_key_id; $this->contents = $this->github_app->contents; @@ -231,7 +225,7 @@ class Change extends Component syncGithubAppName($this->github_app); GithubAppPermissionJob::dispatchSync($this->github_app); - $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->github_app->refresh(); $this->syncData(false); $this->isConnected = $this->github_app->isConnected(); $this->name = str($this->github_app->name)->kebab(); @@ -305,7 +299,7 @@ class Change extends Component try { $github_app_uuid = request()->github_app_uuid; $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); - $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->authorize('view', $this->github_app); $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); $this->applications = $this->github_app->applications; @@ -420,7 +414,6 @@ class Change extends Component try { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->organization = normalizeGithubOrganization($this->organization); $this->apiUrl = filled($this->apiUrl) ? $this->apiUrl @@ -442,7 +435,6 @@ class Change extends Component { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->app_id = 1234567890; $this->github_app->installation_id = 1234567890; $this->github_app->save(); @@ -457,8 +449,6 @@ class Change extends Component try { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); - $this->syncData(true); $this->github_app->save(); $this->isConnected = $this->github_app->isConnected(); @@ -475,7 +465,6 @@ class Change extends Component if ($this->github_app->applications->isNotEmpty()) { $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); return; } @@ -484,7 +473,7 @@ class Change extends Component // @can and canGate checks against a deleted model (null team_id TypeError). $this->github_app = null; - return redirect()->route('source.all'); + return redirectRoute($this, 'source.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index dd0284582b..29374105b2 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -338,7 +338,7 @@ class Change extends Component // @can and canGate checks against a deleted model (null team_id TypeError). $this->gitlab_app = null; - return redirect()->route('source.all'); + return redirectRoute($this, 'source.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index 89782d686c..17abd19e51 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -43,7 +43,7 @@ class Show extends Component $this->storage->delete(); - return redirect()->route('storage.index'); + return redirectRoute($this, 'storage.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index 022f6fdeed..31f2e9141d 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -2,8 +2,11 @@ namespace App\Livewire\Subscription; +use App\Actions\Stripe\UpdateSubscriptionQuantity; +use App\Jobs\ServerLimitCheckJob; use App\Models\InstanceSettings; use App\Providers\RouteServiceProvider; +use Illuminate\Support\Facades\Cache; use Livewire\Component; use Stripe\StripeClient; @@ -49,12 +52,19 @@ class Index extends Component return redirect($session->url); } - public function getStripeStatus() + public function getStripeStatus(): mixed { + $team = currentTeam(); + $user = auth()->user(); + abort_unless($team && $user?->isAdminOfTeam($team->id), 403); + try { - $subscription = currentTeam()->subscription; + $subscription = $team->subscription()->first(); + if (! $subscription?->stripe_customer_id) { + return null; + } $stripe = app(StripeClient::class); - $customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id); + $customer = $stripe->customers->retrieve($subscription->stripe_customer_id); if ($customer) { $subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]); $currentTeam = currentTeam()->id ?? null; @@ -65,6 +75,26 @@ class Index extends Component $subscription->update([ 'stripe_subscription_id' => $foundSubscription->id, ]); + if ($status === 'active') { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + 'stripe_plan_id' => data_get($foundSubscription, 'items.data.0.price.id'), + 'stripe_cancel_at_period_end' => data_get($foundSubscription, 'cancel_at_period_end', false), + ]); + if (str(data_get($foundSubscription, 'items.data.0.price.lookup_key'))->contains('dynamic')) { + $quantity = max( + UpdateSubscriptionQuantity::MIN_SERVER_LIMIT, + min((int) data_get($foundSubscription, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT) + ); + $team->update(['custom_server_limit' => $quantity]); + ServerLimitCheckJob::dispatch($team); + } + $team->unsetRelation('subscription'); + Cache::forget('user:'.$user->id.':team:'.$team->id); + + return redirect()->route('subscription.show'); + } if ($status === 'unpaid') { $this->isUnpaid = true; } @@ -82,6 +112,8 @@ class Index extends Component } finally { $this->loading = false; } + + return null; } public function render() diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 65966aea5f..e53c5c677b 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -2,22 +2,28 @@ namespace App\Livewire\Subscription; -use Illuminate\Support\Facades\Auth; +use App\Actions\Stripe\CreateCheckoutSession; +use App\Exceptions\CheckoutUnavailableException; use Livewire\Component; -use Stripe\Checkout\Session; -use Stripe\Stripe; +use RuntimeException; +use Stripe\Exception\ApiErrorException; class PricingPlans extends Component { - public function subscribeStripe($type) + public function subscribeStripe(string $type): mixed { - if (currentTeam()->subscription?->stripe_invoice_paid) { - $this->dispatch('error', 'Team already has an active subscription.'); + $team = currentTeam(); + $user = auth()->user(); - return; + if (! $team || ! $user?->isAdminOfTeam($team->id)) { + abort(403); } - Stripe::setApiKey(config('subscription.stripe_api_key')); + if ($team->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return null; + } $priceId = match ($type) { 'dynamic-monthly' => config('subscription.stripe_price_id_dynamic_monthly'), @@ -28,48 +34,29 @@ class PricingPlans extends Component if (! $priceId) { $this->dispatch('error', 'Price ID not found! Please contact the administrator.'); - return; + return null; } - $payload = [ - 'allow_promotion_codes' => true, - 'billing_address_collection' => 'required', - 'client_reference_id' => Auth::id().':'.currentTeam()->id, - 'line_items' => [[ - 'price' => $priceId, - 'adjustable_quantity' => [ - 'enabled' => true, - 'minimum' => 2, - ], - 'quantity' => 2, - ]], - 'tax_id_collection' => [ - 'enabled' => true, - ], - 'automatic_tax' => [ - 'enabled' => true, - ], - 'subscription_data' => [ - 'metadata' => [ - 'user_id' => Auth::id(), - 'team_id' => currentTeam()->id, - ], - ], - 'payment_method_collection' => 'if_required', - 'mode' => 'subscription', - 'success_url' => route('dashboard', ['success' => true]), - 'cancel_url' => route('subscription.index', ['cancelled' => true]), - ]; + try { + $session = app(CreateCheckoutSession::class)->execute($team, $user, $priceId); + } catch (ApiErrorException $exception) { + report($exception); + $this->dispatch('error', 'Unable to confirm checkout with Stripe. Please try again shortly.'); - $customer = currentTeam()->subscription?->stripe_customer_id ?? null; - if ($customer) { - $payload['customer'] = $customer; - $payload['customer_update'] = [ - 'name' => 'auto', - ]; - } else { - $payload['customer_email'] = Auth::user()->email; + return null; + } catch (CheckoutUnavailableException $exception) { + $message = $exception->getMessage(); + if ($exception->billingPortalUrl) { + $message .= ' Open billing portal'; + } + $this->dispatch('error', $message); + + return null; + } catch (RuntimeException $exception) { + report($exception); + $this->dispatch('error', 'Unable to start checkout. Please try again shortly.'); + + return null; } - $session = Session::create($payload); return redirect($session->url, 303); } diff --git a/app/Livewire/SwitchTeam.php b/app/Livewire/SwitchTeam.php index d50f57c141..3df399b100 100644 --- a/app/Livewire/SwitchTeam.php +++ b/app/Livewire/SwitchTeam.php @@ -19,7 +19,7 @@ class SwitchTeam extends Component $this->switch_to($this->selectedTeamId); } - public function switch_to($team_id, ?string $currentUrl = null) + public function switch_to($team_id) { if (! auth()->user()->teams->contains($team_id)) { return; @@ -30,12 +30,6 @@ class SwitchTeam extends Component } refreshSession($team_to_switch_to); - $parsedUrl = parse_url($currentUrl ?? '/dashboard'); - $redirectUrl = data_get($parsedUrl, 'path', '/dashboard'); - if ($query = data_get($parsedUrl, 'query')) { - $redirectUrl .= '?'.$query; - } - - return redirect($redirectUrl); + return redirect()->route('dashboard'); } } diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index abec26dc36..9ae34b5610 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -23,12 +23,15 @@ class Index extends Component public bool $is_mcp_server_enabled = true; + public bool $is_build_server_fallback_enabled = true; + protected function rules(): array { return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'is_mcp_server_enabled' => 'boolean', + 'is_build_server_fallback_enabled' => 'boolean', ]; } @@ -59,6 +62,7 @@ class Index extends Component $this->team->name = $this->name; $this->team->description = $this->description; $this->team->is_mcp_server_enabled = $this->is_mcp_server_enabled; + $this->team->is_build_server_fallback_enabled = $this->is_build_server_fallback_enabled; } else { // Sync FROM model (on load/refresh) $this->name = $this->team->name; @@ -66,6 +70,7 @@ class Index extends Component // Null can appear after Team::create() when the DB default is not // hydrated onto the in-memory model stored in session. $this->is_mcp_server_enabled = (bool) ($this->team->is_mcp_server_enabled ?? true); + $this->is_build_server_fallback_enabled = (bool) ($this->team->is_build_server_fallback_enabled ?? true); } } diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index d99fd2eb1b..f28087056f 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -92,6 +92,7 @@ class Member extends Component DB::transaction(function () use ($teamId): void { $this->member->teams()->detach($teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + $this->member->clearStoredTeamIfMatches($teamId); }); auditLog('ui.team_member.removed', [ 'team_id' => $teamId, diff --git a/app/Mcp/Concerns/ResolvesResource.php b/app/Mcp/Concerns/ResolvesResource.php index 7695fb9e25..eb002234a8 100644 --- a/app/Mcp/Concerns/ResolvesResource.php +++ b/app/Mcp/Concerns/ResolvesResource.php @@ -98,6 +98,8 @@ trait ResolvesResource */ protected function normalizeMcpLogLines(mixed $lines): int { - return normalizeLogLines($lines, default: 100, max: 500); + $lines = normalizeLogLines($lines, default: 100, max: 500); + + return is_int($lines) && $lines > 0 ? $lines : 100; } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 2fa1cff990..264d6e38b0 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -3,11 +3,15 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; +use App\Enums\BuildPackTypes; use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; use App\Traits\Auditable; + use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; @@ -128,13 +132,20 @@ class Application extends BaseModel public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; + public const MAX_DOCKER_COMPOSE_COLLECTION_ALIASES = 256; + private static $parserVersion = '5'; + protected $attributes = [ + 'max_restart_count' => 0, + ]; + protected $fillable = [ 'name', 'description', 'fqdn', 'noindex_domains', + 'domain_port_overrides', 'git_repository', 'git_branch', 'git_commit_sha', @@ -213,6 +224,8 @@ class Application extends BaseModel 'last_online_at', 'restart_count', 'max_restart_count', + 'restart_limit_reached', + 'container_present', 'last_restart_at', 'last_restart_type', 'uuid', @@ -244,6 +257,7 @@ class Application extends BaseModel 'docker_compose_raw', 'custom_labels', 'domain_dns_statuses', + 'domain_port_overrides', ]; protected function casts(): array @@ -256,8 +270,11 @@ class Application extends BaseModel 'manual_webhook_secret_gitea' => 'encrypted', 'noindex_domains' => 'array', 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'restart_count' => 'integer', 'max_restart_count' => 'integer', + 'restart_limit_reached' => 'boolean', + 'container_present' => 'boolean', 'last_restart_at' => 'datetime', ]; } @@ -282,6 +299,11 @@ class Application extends BaseModel if ($application->fqdn === '') { $application->fqdn = null; } + if ($application->build_pack !== BuildPackTypes::DOCKERCOMPOSE->value || filled($application->fqdn)) { + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; + } $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } @@ -606,36 +628,8 @@ class Application extends BaseModel public function stoppedAfterRestartLimit(): bool { return str($this->status)->startsWith('exited') - && ($this->restart_count ?? 0) > 0 - && ($this->max_restart_count ?? 0) > 0 - && $this->restart_count >= $this->max_restart_count - && $this->last_restart_type === 'crash'; - } - - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.application.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'application_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; + && $this->container_present === true + && $this->restart_limit_reached === true; } public function settings() @@ -677,15 +671,13 @@ class Application extends BaseModel return "{$this->source->html_url}/{$this->git_repository}/tree/{$this->git_branch}{$base_dir}"; } - // Convert the SSH URL to HTTPS URL - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - + $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + if (is_string($httpsRepository)) { if (str($this->git_repository)->contains('bitbucket')) { - return "https://{$git_repository}/src/{$this->git_branch}{$base_dir}"; + return "{$httpsRepository}/src/{$this->git_branch}{$base_dir}"; } - return "https://{$git_repository}/tree/{$this->git_branch}{$base_dir}"; + return "{$httpsRepository}/tree/{$this->git_branch}{$base_dir}"; } return $this->git_repository; @@ -700,11 +692,9 @@ class Application extends BaseModel if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/settings/hooks"; } - // Convert the SSH URL to HTTPS URL - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - - return "https://{$git_repository}/settings/hooks"; + $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + if (is_string($httpsRepository)) { + return "{$httpsRepository}/settings/hooks"; } return $this->git_repository; @@ -719,11 +709,9 @@ class Application extends BaseModel if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}"; } - // Convert the SSH URL to HTTPS URL - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - - return "https://{$git_repository}/commits/{$this->git_branch}"; + $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + if (is_string($httpsRepository)) { + return "{$httpsRepository}/commits/{$this->git_branch}"; } return $this->git_repository; @@ -731,7 +719,7 @@ class Application extends BaseModel ); } - public function gitCommitLink($link): string + public function gitCommitLink($link): ?string { if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) { if (str($this->source->html_url)->contains('bitbucket')) { @@ -740,24 +728,36 @@ class Application extends BaseModel return "{$this->source->html_url}/{$this->git_repository}/commit/{$link}"; } - if (str($this->git_repository)->contains('bitbucket')) { - $git_repository = str_replace('.git', '', $this->git_repository); - $url = Url::fromString($git_repository); - $url = $url->withUserInfo(''); - $url = $url->withPath($url->getPath().'/commits/'.$link); - return $url->__toString(); - } - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - if (data_get($this, 'source.html_url')) { - return "{$this->source->html_url}/{$git_repository}/commit/{$link}"; - } - - return "{$git_repository}/commit/{$link}"; + $git_repository = $this->git_repository; + $httpsRepository = scpStyleGitUrlToHttps($git_repository); + if (is_string($httpsRepository)) { + $git_repository = $httpsRepository; + } elseif (str($this->git_repository)->startsWith('ssh://')) { + $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } - return $this->git_repository; + if (! filter_var($git_repository, FILTER_VALIDATE_URL)) { + return null; + } + + $url = Url::fromString(Str::replaceEnd('.git', '', $git_repository)); + $url = $url->withUserInfo(''); + $commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit'; + $url = $url->withPath(Str::finish($url->getPath(), '/').$commitPath.'/'.$link); + + return $url->__toString(); + } + + private function httpsUrlFromScpStyleGitRepository(): ?string + { + $httpsRepository = scpStyleGitUrlToHttps($this->git_repository); + + if (! is_string($httpsRepository)) { + return null; + } + + return Str::replaceEnd('.git', '', $httpsRepository); } public function dockerfileLocation(): Attribute @@ -977,6 +977,50 @@ class Application extends BaseModel return $this->settings->is_static ? [80] : $this->ports_exposes_array; } + /** + * Ports declared by the selected Compose service, or exposed and previously used application ports. + * + * @return list + */ + public function availableInternalPorts(?string $serviceName = null): array + { + if ($this->build_pack === 'dockercompose') { + return dockerComposeServicePorts($this->docker_compose_raw, $serviceName); + } + + $ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array) + ->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0) + ->map(fn (mixed $port): int => (int) $port); + + foreach ($this->domain_port_overrides ?? [] as $port) { + if (is_numeric($port) && (int) $port > 0) { + $ports->push((int) $port); + } + } + + foreach (explode(',', (string) $this->fqdn) as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + $legacyPort = DomainUrlParts::split($url)['port'] ?? ''; + if ($legacyPort !== '' && is_numeric($legacyPort) && (int) $legacyPort > 0) { + $ports->push((int) $legacyPort); + } + } + + return $ports->unique()->sort()->values()->all(); + } + + public function portRequiresConfirmation(?int $port, ?string $serviceName = null): bool + { + if ($port === null || $port <= 0) { + return false; + } + + return ! in_array($port, $this->availableInternalPorts($serviceName), true); + } + public function detectPortFromEnvironment(?bool $isPreview = false): ?int { $envVars = $isPreview @@ -1447,7 +1491,7 @@ class Application extends BaseModel // Check if .gitmodules file exists before running submodule commands $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then"; if ($public) { - $git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&"; + $git_clone_command = "{$git_clone_command} sed -i \"s#[A-Za-z0-9._-]*@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&"; } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; @@ -2032,7 +2076,10 @@ class Application extends BaseModel public function oldRawParser() { try { - $yaml = Yaml::parse($this->docker_compose_raw); + $yaml = Yaml::parse( + $this->docker_compose_raw, + maxAliasesForCollections: self::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES, + ); } catch (\Exception $e) { throw new RuntimeException($e->getMessage()); } diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 0905242753..d15142b6ad 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -2,14 +2,20 @@ namespace App\Models; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; +use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\SoftDeletes; use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel { - use SoftDeletes; + use HasRestartLimit, SoftDeletes; + + protected $attributes = [ + 'max_restart_count' => 0, + ]; protected $fillable = [ 'uuid', @@ -23,10 +29,18 @@ class ApplicationPreview extends BaseModel 'docker_compose_domains', 'docker_registry_image_tag', 'last_online_at', + 'domain_dns_statuses', + 'domain_port_overrides', + ]; + + protected $hidden = [ + 'domain_port_overrides', ]; protected $casts = [ 'pull_request_id' => 'integer', + 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', ]; protected static function booted(): void @@ -82,6 +96,14 @@ class ApplicationPreview extends BaseModel if ($preview->isDirty('status')) { $preview->last_online_at = now(); } + if ($preview->isDirty('fqdn')) { + if ($preview->fqdn === '') { + $preview->fqdn = null; + } + $normalized = DomainPortOverrides::normalize($preview->fqdn, $preview->domain_port_overrides); + $preview->fqdn = $normalized['fqdn']; + $preview->domain_port_overrides = $normalized['overrides']; + } }); } @@ -100,39 +122,42 @@ class ApplicationPreview extends BaseModel return $this->belongsTo(Application::class); } + public function restartLimitMaximum(): int + { + return $this->application->max_restart_count ?? $this->max_restart_count ?? 0; + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); } - public function generate_preview_fqdn() + public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = false) { - if ($this->application->fqdn) { - if (str($this->application->fqdn)->contains(',')) { - $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); - } else { - $url = Url::fromString($this->application->fqdn); - } - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $this->fqdn = $preview_fqdn; + $applicationFqdn = $this->application->fqdn; + if (! $applicationFqdn && $generateWithoutApplicationDomain) { + $applicationFqdn = generateUrl( + server: $this->application->destination->server, + random: $this->application->uuid, + ); + } + + if ($applicationFqdn) { + $sourceDomain = str($applicationFqdn)->contains(',') + ? str($applicationFqdn)->explode(',')[0] + : $applicationFqdn; + $generated = $this->generatedPreviewDomain((string) $sourceDomain); + $this->fqdn = $generated['url']; + $this->domain_port_overrides = filled($generated['port']) + ? [$generated['url'] => $generated['port']] + : null; $this->save(); } return $this; } - public function generate_preview_fqdn_compose() + public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDomain = false) { $applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []; $previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: []; @@ -171,11 +196,19 @@ class ApplicationPreview extends BaseModel ->all(); $docker_compose_domains = []; + $previewPortOverrides = []; foreach ($serviceNames as $service_name) { $domain_string = getComposeServiceDomainString($applicationDomains, $service_name); - // If domain string is empty or null, don't auto-generate domain - // Only generate domains when main app already has domains set + if (empty($domain_string)) { + if ($generateWithoutApplicationDomain) { + $domain_string = generateUrl( + server: $this->application->destination->server, + random: str($service_name)->slug().'-'.$this->application->uuid, + ); + } + } + if (empty($domain_string)) { $docker_compose_domains = putComposeServiceDomain( $docker_compose_domains, @@ -195,20 +228,11 @@ class ApplicationPreview extends BaseModel continue; } - $url = Url::fromString($domain); - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $preview_domains[] = $preview_fqdn; + $generated = $this->generatedPreviewDomain((string) $domain); + $preview_domains[] = $generated['url']; + if (filled($generated['port'])) { + $previewPortOverrides[$generated['url']] = $generated['port']; + } } $docker_compose_domains = putComposeServiceDomain( @@ -232,10 +256,36 @@ class ApplicationPreview extends BaseModel ->implode(','); $this->fqdn = ! empty($allDomains) ? $allDomains : null; + $this->domain_port_overrides = $previewPortOverrides ?: null; $this->save(); } + /** + * @return array{url: string, port: ?int} + */ + public function generatedPreviewDomain(string $sourceDomain): array + { + $url = Url::fromString($sourceDomain); + $template = $this->application->preview_url_template; + $host = $url->getHost(); + $schema = $url->getScheme(); + $urlPath = $url->getPath(); + $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; + $random = new_public_id(); + $previewFqdn = str_replace('{{random}}', $random, $template); + $previewFqdn = str_replace('{{domain}}', $host, $previewFqdn); + $previewFqdn = str_replace('{{pr_id}}', (string) $this->pull_request_id, $previewFqdn); + $previewUrl = "{$schema}://{$previewFqdn}{$path}"; + $sourceCanonical = DomainPortOverrides::withoutPort($sourceDomain); + $port = $url->getPort() ?? ($this->application->domain_port_overrides[$sourceCanonical] ?? null); + + return [ + 'url' => $previewUrl, + 'port' => $port !== null ? (int) $port : null, + ]; + } + /** * Original compose service names for this preview (PR suffix stripped), excluding database images. * diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 91c38b8790..18b26f454f 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -32,6 +32,7 @@ use OpenApi\Attributes as OA; 'is_stripprefix_enabled' => ['type' => 'boolean'], 'connect_to_docker_network' => ['type' => 'boolean'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true], 'is_container_label_escape_enabled' => ['type' => 'boolean'], 'is_env_sorting_enabled' => ['type' => 'boolean'], 'is_container_label_readonly_enabled' => ['type' => 'boolean'], @@ -49,6 +50,12 @@ use OpenApi\Attributes as OA; )] class ApplicationSetting extends Model { + /** + * Keeps generated names (prefix, timestamp and for compose apps the service name) well below the + * 63 character DNS label limit, with room for a longer suffix in the future. + */ + public const MAX_CONTAINER_NAME_PREFIX_LENGTH = 30; + protected $casts = [ 'is_static' => 'boolean', 'is_spa' => 'boolean', @@ -106,6 +113,7 @@ class ApplicationSetting extends Model 'is_stripprefix_enabled', 'connect_to_docker_network', 'custom_internal_name', + 'custom_container_name_prefix', 'is_container_label_escape_enabled', 'is_env_sorting_enabled', 'is_container_label_readonly_enabled', @@ -121,6 +129,18 @@ class ApplicationSetting extends Model 'stop_grace_period', ]; + /** + * Like custom container names, a prefix must be unique per server so that uuid, custom container + * name and prefix each identify one container when resolving connections. + */ + public static function isContainerNamePrefixInUse(string $prefix, Server $server, ?int $ignoreApplicationId = null): bool + { + return $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) { + return $application->id !== $ignoreApplicationId + && in_array($prefix, [$application->uuid, $application->settings->custom_container_name_prefix, $application->settings->custom_internal_name], true); + }); + } + public function stopGracePeriodSeconds(): int { if ( diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 2383dee267..4f469fdbeb 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -22,6 +22,7 @@ class AuditEvent extends Model 'event', 'source', 'action', + 'level', 'actor_type', 'actor_id', 'actor_name', @@ -83,10 +84,10 @@ class AuditEvent extends Model /** * @param array $context */ - public static function record(string $event, array $context = []): void + public static function record(string $event, array $context = [], string $level = 'info'): void { try { - $attributes = self::attributesFor($event, $context); + $attributes = self::attributesFor($event, $context, $level); DB::afterCommit(function () use ($attributes): void { defer(function () use ($attributes): void { @@ -112,7 +113,7 @@ class AuditEvent extends Model * @param array $context * @return array */ - private static function attributesFor(string $event, array $context): array + private static function attributesFor(string $event, array $context, string $level): array { $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') ?? data_get($context, 'team_id') @@ -139,10 +140,11 @@ class AuditEvent extends Model 'event' => $event, 'source' => $source, 'action' => $action, + 'level' => self::normalizeLevel($level), 'actor_type' => $actorType, - 'actor_id' => $user?->id, - 'actor_name' => $user?->name, - 'actor_email' => $user?->email, + 'actor_id' => data_get($context, 'actor_id', $user?->id), + 'actor_name' => data_get($context, 'actor_name', $user?->name), + 'actor_email' => data_get($context, 'actor_email', $user?->email), 'actor_token_id' => $token?->id, 'actor_token_name' => $token?->name, 'resource_type' => $resourceType, @@ -156,6 +158,16 @@ class AuditEvent extends Model ]; } + public static function normalizeLevel(string $level): string + { + return in_array($level, ['info', 'warning', 'error'], true) ? $level : 'info'; + } + + public static function redactContext(array $context): array + { + return self::redact($context); + } + /** * @param array $context */ @@ -194,7 +206,7 @@ class AuditEvent extends Model private static function redact(mixed $value, ?string $key = null): mixed { - if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) { + if ($key !== null && self::isSensitiveKey($key)) { return '[REDACTED]'; } @@ -208,4 +220,13 @@ class AuditEvent extends Model ]) ->all(); } + + private static function isSensitiveKey(string $key): bool + { + if (preg_match('/_(id|uuid|name)$/i', $key)) { + return false; + } + + return (bool) preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie|license_key/i', $key); + } } diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index 135c921f61..48d5b5d293 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -20,6 +20,7 @@ class DiscordNotificationSettings extends Model 'deployment_success_discord_notifications', 'deployment_failure_discord_notifications', 'status_change_discord_notifications', + 'restart_limit_reached_discord_notifications', 'backup_success_discord_notifications', 'backup_failure_discord_notifications', 'scheduled_task_success_discord_notifications', @@ -45,6 +46,7 @@ class DiscordNotificationSettings extends Model 'deployment_success_discord_notifications' => 'boolean', 'deployment_failure_discord_notifications' => 'boolean', 'status_change_discord_notifications' => 'boolean', + 'restart_limit_reached_discord_notifications' => 'boolean', 'backup_success_discord_notifications' => 'boolean', 'backup_failure_discord_notifications' => 'boolean', 'scheduled_task_success_discord_notifications' => 'boolean', diff --git a/app/Models/DnsProviderZone.php b/app/Models/DnsProviderZone.php new file mode 100644 index 0000000000..0e099ee0c4 --- /dev/null +++ b/app/Models/DnsProviderZone.php @@ -0,0 +1,24 @@ +belongsTo(IntegrationToken::class); + } + + public function managedRecords(): HasMany + { + return $this->hasMany(ManagedDnsRecord::class); + } +} diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 814d053395..3b04b482af 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -31,6 +31,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications', 'deployment_failure_email_notifications', 'status_change_email_notifications', + 'restart_limit_reached_email_notifications', 'backup_success_email_notifications', 'backup_failure_email_notifications', 'scheduled_task_success_email_notifications', @@ -73,6 +74,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications' => 'boolean', 'deployment_failure_email_notifications' => 'boolean', 'status_change_email_notifications' => 'boolean', + 'restart_limit_reached_email_notifications' => 'boolean', 'backup_success_email_notifications' => 'boolean', 'backup_failure_email_notifications' => 'boolean', 'scheduled_task_success_email_notifications' => 'boolean', diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 02f3e7ed50..1e7d8282a5 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -57,6 +57,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'image_cdn_url', 'is_dashboard_force_https_enabled', ]; diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 53b4dd6f4a..25c2f55939 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -2,11 +2,14 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class IntegrationToken extends BaseModel { + use HasFactory; + public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault']; public const PROVIDER_NAMES = [ @@ -48,6 +51,16 @@ class IntegrationToken extends BaseModel return $this->hasMany(SecretManagerLink::class); } + public function dnsZones(): HasMany + { + return $this->hasMany(DnsProviderZone::class); + } + + public function managedDnsRecords(): HasMany + { + return $this->hasMany(ManagedDnsRecord::class); + } + public function isSecretManager(): bool { return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true); @@ -58,6 +71,11 @@ class IntegrationToken extends BaseModel return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); } + public function automaticDnsEnabled(): bool + { + return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false; + } + public function dopplerTokenType(): ?string { if ($this->provider !== 'doppler') { diff --git a/app/Models/ManagedDnsRecord.php b/app/Models/ManagedDnsRecord.php new file mode 100644 index 0000000000..a025cce0cb --- /dev/null +++ b/app/Models/ManagedDnsRecord.php @@ -0,0 +1,32 @@ +belongsTo(DnsProviderZone::class, 'dns_provider_zone_id'); + } + + public function integrationToken(): BelongsTo + { + return $this->belongsTo(IntegrationToken::class); + } + + public function resource(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index dd0d81cc0e..2ab6693142 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -21,6 +21,7 @@ class PushoverNotificationSettings extends Model 'deployment_success_pushover_notifications', 'deployment_failure_pushover_notifications', 'status_change_pushover_notifications', + 'restart_limit_reached_pushover_notifications', 'backup_success_pushover_notifications', 'backup_failure_pushover_notifications', 'scheduled_task_success_pushover_notifications', @@ -47,6 +48,7 @@ class PushoverNotificationSettings extends Model 'deployment_success_pushover_notifications' => 'boolean', 'deployment_failure_pushover_notifications' => 'boolean', 'status_change_pushover_notifications' => 'boolean', + 'restart_limit_reached_pushover_notifications' => 'boolean', 'backup_success_pushover_notifications' => 'boolean', 'backup_failure_pushover_notifications' => 'boolean', 'scheduled_task_success_pushover_notifications' => 'boolean', diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index e41c793c86..7a26658e4f 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -14,6 +14,9 @@ class ScheduledDatabaseBackup extends BaseModel 'dump_all' => 'boolean', 'database_backup_retention_max_storage_locally' => 'float', 'database_backup_retention_max_storage_s3' => 'float', + 'missing_backup_notification_days' => 'integer', + 'missing_backup_notification_sent_at' => 'datetime', + 'last_execution_at' => 'datetime', ]; } @@ -37,6 +40,7 @@ class ScheduledDatabaseBackup extends BaseModel 'database_backup_retention_max_storage_s3', 'timeout', 'disable_local_backup', + 'missing_backup_notification_days', ]; public static function ownedByCurrentTeam() diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 8c5de1e8b1..1a479772cf 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -6,6 +6,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class ScheduledDatabaseBackupExecution extends BaseModel { + protected static function booted(): void + { + static::created(function (ScheduledDatabaseBackupExecution $execution): void { + $execution->scheduledDatabaseBackup()->update(['last_execution_at' => $execution->created_at ?? now()]); + }); + } + protected $fillable = [ 'uuid', 'scheduled_database_backup_id', diff --git a/app/Models/ScheduledJobDelivery.php b/app/Models/ScheduledJobDelivery.php new file mode 100644 index 0000000000..7772a3f707 --- /dev/null +++ b/app/Models/ScheduledJobDelivery.php @@ -0,0 +1,29 @@ + 'immutable_datetime', + 'payload' => 'array', + 'enqueued_at' => 'immutable_datetime', + 'started_at' => 'immutable_datetime', + ]; + } +} diff --git a/app/Models/ScheduledJobState.php b/app/Models/ScheduledJobState.php new file mode 100644 index 0000000000..e267cd6a1d --- /dev/null +++ b/app/Models/ScheduledJobState.php @@ -0,0 +1,18 @@ + 'immutable_datetime', + ]; + } +} diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 1e26c7be3f..8f496fc1e6 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel 'started_at' => 'datetime', 'finished_at' => 'datetime', 'retry_count' => 'integer', - 'duration' => 'decimal:2', + 'duration' => 'float', ]; } diff --git a/app/Models/Server.php b/app/Models/Server.php index 738bcbfec5..20952d5598 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -8,6 +8,7 @@ use App\Actions\Server\InstallPrerequisites; use App\Actions\Server\StartSentinel; use App\Actions\Server\ValidatePrerequisites; use App\Enums\ProxyTypes; +use App\Enums\ServerRole; use App\Events\ServerReachabilityChanged; use App\Helpers\SslHelper; use App\Jobs\CheckAndStartSentinelJob; @@ -263,8 +264,8 @@ class Server extends BaseModel 'delete_unused_volumes' => 'boolean', 'delete_unused_networks' => 'boolean', 'unreachable_notification_sent' => 'boolean', - 'is_build_server' => 'boolean', 'force_disabled' => 'boolean', + 'sentinel_waiting_since' => 'datetime', ]; /** @@ -522,17 +523,24 @@ class Server extends BaseModel private static function usableByBuildServerStatus(bool $isBuildServer): Builder { - return Server::ownedByCurrentTeam() + $query = Server::ownedByCurrentTeam() ->whereRelation('settings', 'is_reachable', true) ->whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_swarm_worker', false) - ->whereRelation('settings', 'is_build_server', $isBuildServer) ->whereRelation('settings', 'force_disabled', false); + + return $isBuildServer + ? $query->whereHas('settings', fn (Builder $settings) => $settings + ->where('server_role', '!=', ServerRole::DEPLOYMENT->value) + ->orWhereNull('server_role')) + : $query->whereHas('settings', fn (Builder $settings) => $settings + ->where('server_role', '!=', ServerRole::BUILD->value) + ->orWhereNull('server_role')); } public function canHostResources(): bool { - return ! $this->isBuildServer(); + return $this->settings->effectiveServerRole()->canDeploy(); } public function settings() @@ -547,7 +555,7 @@ class Server extends BaseModel public function proxySet() { - return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && ! $this->settings->is_build_server; + return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources(); } public function setupDefaultRedirect() @@ -923,7 +931,14 @@ $siteAddress { public static function buildServers($teamId) { - return Server::whereTeamId($teamId)->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_build_server', true); + return Server::whereTeamId($teamId) + ->whereRelation('settings', 'is_reachable', true) + ->whereRelation('settings', 'is_usable', true) + ->whereRelation('settings', 'is_swarm_worker', false) + ->whereHas('settings', fn (Builder $settings) => $settings + ->where('server_role', '!=', ServerRole::DEPLOYMENT->value) + ->orWhereNull('server_role')) + ->whereRelation('settings', 'force_disabled', false); } public function isForceDisabled() @@ -988,22 +1003,46 @@ $siteAddress { return $wait; } + public function firstSentinelReportTimeoutSeconds(): int + { + return max(30, $this->settings->sentinel_push_interval_seconds + 30); + } + public function isSentinelLive() { return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subSeconds($this->waitBeforeDoingSshCheck())); } - public function isSentinelEnabled() + public function sentinelStatus(): string { - return ($this->isMetricsEnabled() || $this->isServerApiEnabled()) && ! $this->isBuildServer(); + if ($this->sentinel_waiting_since !== null) { + return $this->sentinel_waiting_since->isAfter(now()->subSeconds($this->firstSentinelReportTimeoutSeconds())) + ? 'waiting' + : 'out_of_sync'; + } + + return $this->isSentinelLive() ? 'in_sync' : 'out_of_sync'; } - public function isMetricsEnabled() + public function isSentinelEnabled(): bool + { + return ! $this->isBuildServer() + && ! $this->isSwarm() + && ! $this->isForceDisabled() + && ! $this->isTransferredAway(); + } + + public function isMetricsEnabled(): bool { return $this->settings->is_metrics_enabled; } - public function isServerApiEnabled() + public function isTrafficAnalyticsEnabled(): bool + { + return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false); + } + + public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; } @@ -1651,7 +1690,7 @@ $siteAddress { } $this->settings->is_usable = true; $this->settings->save(); - $this->validateCoolifyNetwork(isSwarm: false, isBuildServer: $this->settings->is_build_server); + $this->validateCoolifyNetwork(isSwarm: false, isBuildServer: $this->isBuildServer()); return true; } @@ -1762,7 +1801,12 @@ $siteAddress { public function isBuildServer() { - return $this->settings->is_build_server; + return $this->settings->effectiveServerRole() === ServerRole::BUILD; + } + + public function canBuildApplications(): bool + { + return $this->settings->effectiveServerRole()->canBuild(); } public static function createWithPrivateKey(array $data, PrivateKey $privateKey) @@ -1833,6 +1877,8 @@ $siteAddress { $this->proxy->set('last_saved_proxy_configuration', null); $this->proxy->set('last_saved_settings', null); $this->proxy->set('last_applied_settings', null); + $this->detected_traefik_version = null; + $this->traefik_outdated_info = null; $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index c3fa8721c4..624458ef91 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ServerRole; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; @@ -19,7 +20,7 @@ use OpenApi\Attributes as OA; 'dynamic_timeout' => ['type' => 'integer'], 'force_disabled' => ['type' => 'boolean'], 'force_server_cleanup' => ['type' => 'boolean'], - 'is_build_server' => ['type' => 'boolean'], + 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both']], 'is_cloudflare_tunnel' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], @@ -27,6 +28,13 @@ use OpenApi\Attributes as OA; 'is_logdrain_highlight_enabled' => ['type' => 'boolean'], 'is_logdrain_newrelic_enabled' => ['type' => 'boolean'], 'is_metrics_enabled' => ['type' => 'boolean'], + 'is_traffic_analytics_enabled' => ['type' => 'boolean'], + 'traffic_topn' => ['type' => 'integer'], + 'traffic_sample_threshold' => ['type' => 'integer'], + 'traffic_retention_1h_days' => ['type' => 'integer'], + 'traffic_retention_1d_days' => ['type' => 'integer'], + 'is_geoip_enabled' => ['type' => 'boolean'], + 'geoip_refresh_days' => ['type' => 'integer'], 'is_reachable' => ['type' => 'boolean'], 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], @@ -60,11 +68,18 @@ use OpenApi\Attributes as OA; )] class ServerSetting extends Model { + public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10; + + public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7; + + public const int DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS = 60; + protected $fillable = [ 'server_id', 'is_swarm_manager', 'is_jump_server', 'is_build_server', + 'server_role', 'is_reachable', 'is_usable', 'wildcard_domain', @@ -106,6 +121,14 @@ class ServerSetting extends Model 'backup_compression_cpu_percentage', 'disable_application_image_retention', 'connection_timeout', + 'is_traffic_analytics_enabled', + 'traffic_topn', + 'traffic_sample_threshold', + 'traffic_retention_1h_days', + 'traffic_retention_1d_days', + 'is_geoip_enabled', + 'geoip_refresh_days', + 'geoip_maxmind_license_key', 'docker_version', 'docker_version_checked_at', 'compose_version', @@ -120,9 +143,18 @@ class ServerSetting extends Model 'is_reachable' => 'boolean', 'is_usable' => 'boolean', 'is_build_server' => 'boolean', + 'server_role' => ServerRole::class, 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', 'connection_timeout' => 'integer', + 'is_traffic_analytics_enabled' => 'boolean', + 'traffic_topn' => 'integer', + 'traffic_sample_threshold' => 'integer', + 'traffic_retention_1h_days' => 'integer', + 'traffic_retention_1d_days' => 'integer', + 'is_geoip_enabled' => 'boolean', + 'geoip_refresh_days' => 'integer', + 'geoip_maxmind_license_key' => 'encrypted', 'docker_version_checked_at' => 'datetime', 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', @@ -134,12 +166,14 @@ class ServerSetting extends Model * `read:sensitive` or `root` token ability. */ protected $hidden = [ + 'is_build_server', 'sentinel_token', 'sentinel_custom_url', 'logdrain_newrelic_license_key', 'logdrain_axiom_api_key', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'geoip_maxmind_license_key', ]; protected static function booted() @@ -162,13 +196,30 @@ class ServerSetting extends Model $settings->wasChanged('sentinel_custom_url') || $settings->wasChanged('sentinel_metrics_refresh_rate_seconds') || $settings->wasChanged('sentinel_metrics_history_days') || - $settings->wasChanged('sentinel_push_interval_seconds') + $settings->wasChanged('sentinel_push_interval_seconds') || + $settings->wasChanged('traffic_topn') || + $settings->wasChanged('traffic_sample_threshold') || + $settings->wasChanged('traffic_retention_1h_days') || + $settings->wasChanged('traffic_retention_1d_days') || + $settings->wasChanged('is_geoip_enabled') || + $settings->wasChanged('geoip_refresh_days') || + $settings->wasChanged('geoip_maxmind_license_key') ) { - $settings->server->restartSentinel(); + // Only recreate Sentinel when it is already enabled. Otherwise a change to a + // traffic/geoip tuning knob would turn Sentinel on as a side effect, because + // StartSentinel unconditionally sets is_sentinel_enabled = true. + if ($settings->is_sentinel_enabled) { + $settings->server->restartSentinel(); + } } }); } + public function effectiveServerRole(): ServerRole + { + return $this->server_role ?? ($this->is_build_server ? ServerRole::BUILD : ServerRole::BOTH); + } + /** * Validate that a sentinel token contains only safe characters. * Prevents OS command injection when the token is interpolated into shell commands. @@ -236,6 +287,10 @@ class ServerSetting extends Model { $url = $this->sentinel_custom_url; + if ($this->server->isLocalhost() && $url === 'http://host.docker.internal:8000') { + $url = null; + } + if (blank($url)) { $url = $this->generateSentinelUrl(ignoreEvent: true); } @@ -247,12 +302,22 @@ class ServerSetting extends Model return $url; } + public function restoreDefaultSentinelConfiguration(): void + { + $this->generateSentinelUrl(save: false, ignoreEvent: true); + $this->sentinel_metrics_refresh_rate_seconds = self::DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS; + $this->sentinel_metrics_history_days = self::DEFAULT_SENTINEL_METRICS_HISTORY_DAYS; + $this->sentinel_push_interval_seconds = self::DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS; + $this->is_sentinel_debug_enabled = false; + $this->saveQuietly(); + } + public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false): ?string { $domain = null; $settings = InstanceSettings::get(); if ($this->server->isLocalhost()) { - $domain = 'http://host.docker.internal:8000'; + $domain = 'http://coolify:8080'; } elseif ($settings->fqdn) { $domain = $settings->fqdn; } elseif ($settings->public_ipv4) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 429422b90e..6ed5e836f2 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,7 +4,9 @@ namespace App\Models; use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; +use App\Support\DomainPortOverrides; use App\Traits\Auditable; + use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -16,7 +18,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; -use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; #[OA\Schema( @@ -94,11 +95,18 @@ class Service extends BaseModel public function isConfigurationChanged(bool $save = false) { - $domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray(); + $applications = $this->applications()->get(); + $domains = $applications->pluck('fqdn')->sort()->toArray(); $domains = implode(',', $domains); - $noindexDomains = $this->applications()->get()->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $noindexDomains = $applications->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $domainPortOverrides = $applications + ->mapWithKeys(fn (ServiceApplication $application): array => [ + $application->id => DomainPortOverrides::sorted($application->domain_port_overrides), + ]) + ->sortKeys() + ->all(); - $applicationImages = $this->applications()->get()->pluck('image')->sort(); + $applicationImages = $applications->pluck('image')->sort(); $databaseImages = $this->databases()->get()->pluck('image')->sort(); $images = $applicationImages->merge($databaseImages); $images = implode(',', $images->toArray()); @@ -107,7 +115,7 @@ class Service extends BaseModel $databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id'); $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); - $newConfigHash = $images.$domains.$images.$storages.$noindexDomains; + $newConfigHash = $images.$domains.$images.$storages.$noindexDomains.json_encode($domainPortOverrides); $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -1457,32 +1465,6 @@ class Service extends BaseModel return null; } - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.service.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'service_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = InstanceSettings::get(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; - } - public function documentation() { $services = get_service_templates(); @@ -1498,7 +1480,10 @@ class Service extends BaseModel { try { $services = get_service_templates(); - $serviceName = str($this->name)->beforeLast('-')->value(); + if (blank($this->service_type)) { + return null; + } + $serviceName = $this->service_type; $service = data_get($services, $serviceName, []); $port = data_get($service, 'port'); @@ -1605,7 +1590,7 @@ class Service extends BaseModel Storage::disk('local')->delete("tmp/{$filename}"); $commands[] = "cd $workdir"; - $commands[] = 'rm -f .env || true'; + $environmentFilename = new_public_id().'.env.tmp'; $envs = collect([]); @@ -1636,10 +1621,10 @@ class Service extends BaseModel $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); } if ($envs->count() === 0) { - $commands[] = 'touch .env'; + $commands[] = "touch {$environmentFilename} && mv {$environmentFilename} .env"; } else { $envs_base64 = base64_encode($envs->implode("\n")); - $commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null"; + $commands[] = "echo '$envs_base64' | base64 -d | tee {$environmentFilename} > /dev/null && mv {$environmentFilename} .env"; } instant_remote_process($commands, $this->server); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 9763fa894b..066a86fac8 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -2,7 +2,10 @@ namespace App\Models; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; use App\Traits\HasNoindexDomains; +use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -10,7 +13,9 @@ use Symfony\Component\Yaml\Yaml; class ServiceApplication extends BaseModel { - use HasFactory, HasNoindexDomains, SoftDeletes; + use HasFactory, HasNoindexDomains, HasRestartLimit, SoftDeletes; + + protected $appends = ['url']; protected $fillable = [ 'service_id', @@ -21,6 +26,7 @@ class ServiceApplication extends BaseModel 'noindex_domains', 'redirect', 'domain_dns_statuses', + 'domain_port_overrides', 'ports', 'exposes', 'status', @@ -43,16 +49,19 @@ class ServiceApplication extends BaseModel */ protected $hidden = [ 'domain_dns_statuses', + 'domain_port_overrides', ]; protected $attributes = [ 'is_force_https_enabled' => true, + 'max_restart_count' => 0, ]; protected function casts(): array { return [ 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'noindex_domains' => 'array', 'is_force_https_enabled' => 'boolean', ]; @@ -70,6 +79,7 @@ class ServiceApplication extends BaseModel $service->last_online_at = now(); } if ($service->isDirty('fqdn')) { + $service->normalizeDomainPortOverrides(); $service->syncNoindexDomains(); } }); @@ -191,6 +201,45 @@ class ServiceApplication extends BaseModel ); } + /** + * Return editable URLs with persisted overrides or legacy embedded ports. + */ + protected function url(): Attribute + { + return Attribute::make( + get: function (): ?string { + if (blank($this->fqdn)) { + return null; + } + + $overrides = $this->domain_port_overrides ?? []; + + return collect(explode(',', $this->fqdn)) + ->map(function (string $url) use ($overrides): string { + $url = trim($url); + $canonical = DomainPortOverrides::withoutPort($url); + $port = $overrides[$canonical] ?? null; + + if ($port === null) { + return $url; + } + + $parts = DomainUrlParts::split($canonical); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], (string) $port, $parts['path']); + }) + ->implode(','); + }, + ); + } + + public function setEditableUrls(?string $urls): void + { + $normalized = DomainPortOverrides::normalize($urls, null); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Extract port number from a given FQDN URL. * Returns null if no port is specified. @@ -212,6 +261,58 @@ class ServiceApplication extends BaseModel } } + /** + * True when saving this URL should confirm that it does not use the required template port. + */ + public function portRequiresConfirmation(string $fqdn, ?int $requiredPort, ?string $previousFqdn = null): bool + { + if ($requiredPort === null) { + return false; + } + + $fqdn = trim($fqdn); + if ($fqdn === '') { + return false; + } + + $canonical = DomainPortOverrides::withoutPort($fqdn); + $explicit = self::extractPortFromUrl($fqdn); + + if ($explicit === $requiredPort) { + return false; + } + + if ($explicit === null) { + $previous = collect(explode(',', (string) $previousFqdn)) + ->filter(); + $previousUrl = $previous->first( + fn (string $url): bool => DomainPortOverrides::withoutPort(trim($url)) === $canonical + ); + + if (is_string($previousUrl) && self::extractPortFromUrl($previousUrl) !== null) { + return true; + } + + return $previousUrl === null; + } + + $existingOverride = $this->domain_port_overrides[$canonical] ?? null; + + return (int) $existingOverride !== $explicit; + } + + public static function withoutPort(string $url): string + { + return DomainPortOverrides::withoutPort($url); + } + + protected function normalizeDomainPortOverrides(): void + { + $normalized = DomainPortOverrides::normalize($this->fqdn, $this->domain_port_overrides); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Check if all FQDNs have a port specified. */ @@ -266,7 +367,7 @@ class ServiceApplication extends BaseModel } $dockerCompose = Yaml::parse($dockerComposeRaw); - $serviceConfig = data_get($dockerCompose, "services.{$this->name}"); + $serviceConfig = $dockerCompose['services'][$this->name] ?? null; if (! $serviceConfig) { return $this->service->getRequiredPort(); } @@ -276,6 +377,7 @@ class ServiceApplication extends BaseModel // Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment // (not variables that are merely referenced with ${VAR} syntax) $portFound = null; + $declaresHttpUrl = false; foreach ($environment as $key => $value) { if (is_int($key) && is_string($value)) { // List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value" @@ -284,6 +386,7 @@ class ServiceApplication extends BaseModel // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -298,6 +401,7 @@ class ServiceApplication extends BaseModel // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -314,11 +418,75 @@ class ServiceApplication extends BaseModel return $portFound; } - // No port-specific variables found for this service, return null - // (DO NOT fall back to service-level port, as that applies to all services) + $composePort = firstDockerComposeServicePort($serviceConfig); + if ($composePort !== null) { + return $composePort; + } + + // HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT + // suffix), such as WordPress, inherit the one-click template `# port:`. + if ($declaresHttpUrl) { + if (blank($this->service->service_type)) { + $savedPort = $this->getSavedLegacyRoutingPort($serviceConfig); + if ($savedPort !== null) { + return $savedPort; + } + } + + return $this->service->getRequiredPort(); + } + return null; } catch (\Throwable $e) { return null; } } + + /** + * Preserve only an unambiguous upstream from this legacy container's saved labels. + */ + private function getSavedLegacyRoutingPort(array $serviceConfig): ?int + { + $savedCompose = Yaml::parse($this->service->docker_compose ?? ''); + $savedService = $savedCompose['services'][$this->name] ?? null; + $image = $serviceConfig['image'] ?? null; + if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) { + return null; + } + + $labels = $savedService['labels'] ?? []; + if (! is_array($labels)) { + return null; + } + + $ports = []; + foreach ($labels as $key => $value) { + if (is_int($key)) { + if (! is_string($value)) { + return null; + } + [$key, $value] = array_pad(explode('=', $value, 2), 2, null); + } + + if (preg_match('/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/', $key)) { + $port = $value; + } elseif (preg_match('/^caddy(?:_\d+)?\..*reverse_proxy$/', $key)) { + if (! is_string($value) || ! preg_match('/^\{\{upstreams ([0-9]+)\}\}$/', $value, $matches)) { + return null; + } + $port = $matches[1]; + } else { + continue; + } + + if ((! is_string($port) && ! is_int($port)) || ! preg_match('/^[0-9]+$/', (string) $port) || (int) $port < 1 || (int) $port > 65535) { + return null; + } + $ports[] = (int) $port; + } + + $ports = array_values(array_unique($ports)); + + return count($ports) === 1 ? $ports[0] : null; + } } diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index 62603685e9..648869bafe 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -20,6 +20,7 @@ class SlackNotificationSettings extends Model 'deployment_success_slack_notifications', 'deployment_failure_slack_notifications', 'status_change_slack_notifications', + 'restart_limit_reached_slack_notifications', 'backup_success_slack_notifications', 'backup_failure_slack_notifications', 'scheduled_task_success_slack_notifications', @@ -44,6 +45,7 @@ class SlackNotificationSettings extends Model 'deployment_success_slack_notifications' => 'boolean', 'deployment_failure_slack_notifications' => 'boolean', 'status_change_slack_notifications' => 'boolean', + 'restart_limit_reached_slack_notifications' => 'boolean', 'backup_success_slack_notifications' => 'boolean', 'backup_failure_slack_notifications' => 'boolean', 'scheduled_task_success_slack_notifications' => 'boolean', diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 604a245fc7..e7e0a8c108 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -43,14 +43,20 @@ class StandaloneDocker extends BaseModel } $server = $newStandaloneDocker->server; - $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ - "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null", + $newStandaloneDocker->networkCreateCommand(), ], $server, false); ConnectProxyToNetworksJob::dispatchSync($server); }); } + public function networkCreateCommand(): string + { + $safeNetwork = escapeshellarg($this->network); + + return "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --attachable {$safeNetwork} >/dev/null"; + } + public function setNetworkAttribute(string $value): void { if (! ValidationPatterns::isValidDockerNetwork($value)) { diff --git a/app/Models/Team.php b/app/Models/Team.php index 12998be165..b8c22cfb2e 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -29,6 +29,7 @@ use OpenApi\Attributes as OA; 'updated_at' => ['type' => 'string', 'description' => 'The date and time the team was last updated.'], 'show_boarding' => ['type' => 'boolean', 'description' => 'Whether to show the boarding screen or not.'], 'custom_server_limit' => ['type' => 'string', 'description' => 'The custom server limit.'], + 'is_build_server_fallback_enabled' => ['type' => 'boolean', 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.'], 'members' => new OA\Property( property: 'members', type: 'array', @@ -49,15 +50,18 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen 'show_boarding', 'custom_server_limit', 'is_mcp_server_enabled', + 'is_build_server_fallback_enabled', ]; protected $attributes = [ 'is_mcp_server_enabled' => true, + 'is_build_server_fallback_enabled' => true, ]; protected $casts = [ 'personal_team' => 'boolean', 'is_mcp_server_enabled' => 'boolean', + 'is_build_server_fallback_enabled' => 'boolean', ]; protected static function booted() @@ -279,13 +283,22 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(TeamInvitation::class); } - public function isEmpty() + /** + * @return array + */ + public function deletionBlockers(): array { - if ($this->projects()->count() === 0 && $this->servers()->count() === 0 && $this->privateKeys()->count() === 0 && $this->sources()->count() === 0) { - return true; - } + return array_filter([ + 'projects' => $this->projects()->count(), + 'servers' => $this->servers()->count(), + 'sources' => GithubApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count() + + GitlabApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count(), + ]); + } - return false; + public function isEmpty(): bool + { + return $this->deletionBlockers() === []; } public function projects() @@ -298,6 +311,18 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(Server::class); } + public function usesSwarm(): bool + { + return $this->servers() + ->where(function ($query) { + $query->whereHas('settings', function ($settings) { + $settings->where('is_swarm_manager', true) + ->orWhere('is_swarm_worker', true); + })->orWhereHas('swarmDockers'); + }) + ->exists(); + } + public function privateKeys() { return $this->hasMany(PrivateKey::class); diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 8c644f9bcf..3376e239d6 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -21,6 +21,7 @@ class TelegramNotificationSettings extends Model 'deployment_success_telegram_notifications', 'deployment_failure_telegram_notifications', 'status_change_telegram_notifications', + 'restart_limit_reached_telegram_notifications', 'backup_success_telegram_notifications', 'backup_failure_telegram_notifications', 'scheduled_task_success_telegram_notifications', @@ -36,6 +37,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id', 'telegram_notifications_deployment_failure_thread_id', 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_restart_limit_reached_thread_id', 'telegram_notifications_backup_success_thread_id', 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', @@ -55,6 +57,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id', 'telegram_notifications_deployment_failure_thread_id', 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_restart_limit_reached_thread_id', 'telegram_notifications_backup_success_thread_id', 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', @@ -76,6 +79,7 @@ class TelegramNotificationSettings extends Model 'deployment_success_telegram_notifications' => 'boolean', 'deployment_failure_telegram_notifications' => 'boolean', 'status_change_telegram_notifications' => 'boolean', + 'restart_limit_reached_telegram_notifications' => 'boolean', 'backup_success_telegram_notifications' => 'boolean', 'backup_failure_telegram_notifications' => 'boolean', 'scheduled_task_success_telegram_notifications' => 'boolean', @@ -90,6 +94,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id' => 'encrypted', 'telegram_notifications_deployment_failure_thread_id' => 'encrypted', 'telegram_notifications_status_change_thread_id' => 'encrypted', + 'telegram_notifications_restart_limit_reached_thread_id' => 'encrypted', 'telegram_notifications_backup_success_thread_id' => 'encrypted', 'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', diff --git a/app/Models/User.php b/app/Models/User.php index 10303422bd..9f037bb917 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,6 +49,7 @@ class User extends Authenticatable implements SendsEmail 'name', 'email', 'password', + 'current_team_id', 'force_password_reset', 'marketing_emails', 'pending_email', @@ -67,6 +68,7 @@ class User extends Authenticatable implements SendsEmail ]; protected $casts = [ + 'current_team_id' => 'integer', 'email_verified_at' => 'datetime', 'force_password_reset' => 'boolean', 'show_boarding' => 'boolean', @@ -375,6 +377,54 @@ class User extends Authenticatable implements SendsEmail }); } + /** + * Resolve the team to activate when the session has no current team + * (fresh login or an invalidated session). + * + * Returns the user's last active team when they still belong to it, or the + * sole team of a single-team user. Returns null when the choice is ambiguous + * (more than one team and no valid stored preference) — the caller must then + * prompt the user to pick a team instead of defaulting silently. + */ + public function resolveStoredTeam(): ?Team + { + if (! is_null($this->current_team_id)) { + $storedTeam = $this->teams->firstWhere('id', $this->current_team_id); + if ($storedTeam) { + return $storedTeam; + } + } + + if ($this->teams->count() === 1) { + return $this->teams->first(); + } + + return null; + } + + /** + * Reset the persisted active team when it points to the given team. + * + * Called when the user is removed from a team (or the team is deleted) so a + * stale current_team_id can never be trusted after the fact. Read paths + * already re-validate membership; this is defense-in-depth that clears the + * dangling value at the source event instead of relying on self-healing. + */ + public function clearStoredTeamIfMatches(int $teamId): void + { + // Atomic conditional update: only null the column when the database value + // still points at this team, so a newer team selection made concurrently + // (in another request) is preserved rather than clobbered. + static::query() + ->whereKey($this->getKey()) + ->where('current_team_id', $teamId) + ->update(['current_team_id' => null]); + + if ($this->current_team_id === $teamId) { + $this->current_team_id = null; + } + } + public function role(): ?string { if (data_get($this, 'pivot')) { diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index c6a81b50a8..7ffd20a8c3 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -20,6 +20,7 @@ class WebhookNotificationSettings extends Model 'deployment_success_webhook_notifications', 'deployment_failure_webhook_notifications', 'status_change_webhook_notifications', + 'restart_limit_reached_webhook_notifications', 'backup_success_webhook_notifications', 'backup_failure_webhook_notifications', 'scheduled_task_success_webhook_notifications', @@ -46,6 +47,7 @@ class WebhookNotificationSettings extends Model 'deployment_success_webhook_notifications' => 'boolean', 'deployment_failure_webhook_notifications' => 'boolean', 'status_change_webhook_notifications' => 'boolean', + 'restart_limit_reached_webhook_notifications' => 'boolean', 'backup_success_webhook_notifications' => 'boolean', 'backup_failure_webhook_notifications' => 'boolean', 'scheduled_task_success_webhook_notifications' => 'boolean', diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a1..01f58af7ec 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -21,7 +21,7 @@ class ApiTokenExpiringNotification extends CustomEmailNotification $this->onQueue('high'); $this->tokenName = $token->name; $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? ''; - $this->manageUrl = route('security.api-tokens'); + $this->manageUrl = base_url().'/security/api-tokens'; } public function via(object $notifiable): array @@ -100,4 +100,16 @@ class ApiTokenExpiringNotification extends CustomEmailNotification color: SlackMessage::warningColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "API token '{$this->tokenName}' expires on {$this->expiresAt}. Rotate this token before it expires to avoid API outages.", + 'event' => 'api_token_expiring', + 'token_name' => $this->tokenName, + 'expires_at' => $this->expiresAt, + 'url' => $this->manageUrl, + ]; + } } diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 635dfdbdce..de9de1f981 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -3,6 +3,10 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationPreview; +use App\Models\BaseModel; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; @@ -27,26 +31,45 @@ class RestartLimitReached extends CustomEmailNotification public int $max_restart_count; - public function __construct(public Application $resource) + public function __construct(public BaseModel $resource) { $this->onQueue('high'); $this->afterCommit(); - $this->resource_name = data_get($resource, 'name'); - $this->project_uuid = data_get($resource, 'environment.project.uuid'); - $this->environment_uuid = data_get($resource, 'environment.uuid'); - $this->environment_name = data_get($resource, 'environment.name'); + $environment = data_get($resource, 'environment') + ?? data_get($resource, 'application.environment') + ?? data_get($resource, 'service.environment'); + $this->resource_name = $resource instanceof ApplicationPreview + ? data_get($resource, 'application.name').' PR #'.$resource->pull_request_id + : data_get($resource, 'name'); + $this->project_uuid = data_get($environment, 'project.uuid'); + $this->environment_uuid = data_get($environment, 'uuid'); + $this->environment_name = data_get($environment, 'name'); $this->fqdn = data_get($resource, 'fqdn', null); $this->restart_count = $resource->restart_count; - $this->max_restart_count = $resource->max_restart_count; + $this->max_restart_count = method_exists($resource, 'restartLimitMaximum') + ? $resource->restartLimitMaximum() + : $resource->max_restart_count; if (str($this->fqdn)->explode(',')->count() > 1) { $this->fqdn = str($this->fqdn)->explode(',')->first(); } - $this->resource_url = $this->resource->link() ?? base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}"; + $this->resource_url = $this->resolveResourceUrl($resource); + } + + private function resolveResourceUrl(BaseModel $resource): string + { + [$type, $uuid] = match (true) { + $resource instanceof Application => ['application', $resource->uuid], + $resource instanceof ApplicationPreview => ['application', $resource->application->uuid], + $resource instanceof ServiceApplication, $resource instanceof ServiceDatabase => ['service', $resource->service->uuid], + default => ['database', $resource->uuid], + }; + + return base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/{$type}/{$uuid}"; } public function via(object $notifiable): array { - return $notifiable->getEnabledChannels('status_change'); + return $notifiable->getEnabledChannels('restart_limit_reached'); } public function toMail(): MailMessage @@ -68,7 +91,7 @@ class RestartLimitReached extends CustomEmailNotification { return new DiscordMessage( title: ':warning: Restart limit reached', - description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})", + description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Resource in Coolify]({$this->resource_url})", color: DiscordMessage::errorColor(), isCritical: true, ); @@ -82,7 +105,7 @@ class RestartLimitReached extends CustomEmailNotification 'message' => $message, 'buttons' => [ [ - 'text' => 'Open Application in Coolify', + 'text' => 'Open Resource in Coolify', 'url' => $this->resource_url, ], ], @@ -99,7 +122,7 @@ class RestartLimitReached extends CustomEmailNotification message: $message, buttons: [ [ - 'text' => 'Open Application in Coolify', + 'text' => 'Open Resource in Coolify', 'url' => $this->resource_url, ], ], @@ -110,10 +133,13 @@ class RestartLimitReached extends CustomEmailNotification { $title = 'Restart limit reached'; $description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})"; + $environment = data_get($this->resource, 'environment') + ?? data_get($this->resource, 'application.environment') + ?? data_get($this->resource, 'service.environment'); - $description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name'); + $description .= "\n\n*Project:* ".data_get($environment, 'project.name'); $description .= "\n*Environment:* {$this->environment_name}"; - $description .= "\n*Application URL:* {$this->resource_url}"; + $description .= "\n*Resource URL:* {$this->resource_url}"; return new SlackMessage( title: $title, @@ -130,6 +156,8 @@ class RestartLimitReached extends CustomEmailNotification 'event' => 'restart_limit_reached', 'application_name' => $this->resource_name, 'application_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource_name, + 'resource_uuid' => $this->resource->uuid, 'restart_count' => $this->restart_count, 'max_restart_count' => $this->max_restart_count, 'url' => $this->resource_url, diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index c2fa3ff10d..118ad4269c 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -3,6 +3,23 @@ namespace App\Notifications\Channels; use App\Jobs\SendMessageToTelegramJob; +use App\Notifications\Application\DeploymentFailed; +use App\Notifications\Application\DeploymentSuccess; +use App\Notifications\Application\RestartLimitReached; +use App\Notifications\Application\StatusChanged; +use App\Notifications\Container\ContainerRestarted; +use App\Notifications\Database\BackupFailed; +use App\Notifications\Database\BackupMissing; +use App\Notifications\Database\BackupSuccess; +use App\Notifications\ScheduledTask\TaskFailed; +use App\Notifications\ScheduledTask\TaskSuccess; +use App\Notifications\Server\DockerCleanupFailed; +use App\Notifications\Server\DockerCleanupSuccess; +use App\Notifications\Server\HighDiskUsage; +use App\Notifications\Server\Reachable; +use App\Notifications\Server\ServerPatchCheck; +use App\Notifications\Server\TraefikVersionOutdated; +use App\Notifications\Server\Unreachable; class TelegramChannel { @@ -17,25 +34,26 @@ class TelegramChannel $chatId = $settings->telegram_chat_id; $threadId = match (get_class($notification)) { - \App\Notifications\Application\DeploymentSuccess::class => $settings->telegram_notifications_deployment_success_thread_id, - \App\Notifications\Application\DeploymentFailed::class => $settings->telegram_notifications_deployment_failure_thread_id, - \App\Notifications\Application\StatusChanged::class, - \App\Notifications\Container\ContainerRestarted::class, - \App\Notifications\Container\ContainerStopped::class => $settings->telegram_notifications_status_change_thread_id, + DeploymentSuccess::class => $settings->telegram_notifications_deployment_success_thread_id, + DeploymentFailed::class => $settings->telegram_notifications_deployment_failure_thread_id, + StatusChanged::class, + ContainerRestarted::class => $settings->telegram_notifications_status_change_thread_id, + RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id, - \App\Notifications\Database\BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, - \App\Notifications\Database\BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id, + BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, + BackupFailed::class, + BackupMissing::class => $settings->telegram_notifications_backup_failure_thread_id, - \App\Notifications\ScheduledTask\TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, - \App\Notifications\ScheduledTask\TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, - - \App\Notifications\Server\DockerCleanupSuccess::class => $settings->telegram_notifications_docker_cleanup_success_thread_id, - \App\Notifications\Server\DockerCleanupFailed::class => $settings->telegram_notifications_docker_cleanup_failure_thread_id, - \App\Notifications\Server\HighDiskUsage::class => $settings->telegram_notifications_server_disk_usage_thread_id, - \App\Notifications\Server\Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, - \App\Notifications\Server\Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, - \App\Notifications\Server\ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, + TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, + TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, + DockerCleanupSuccess::class => $settings->telegram_notifications_docker_cleanup_success_thread_id, + DockerCleanupFailed::class => $settings->telegram_notifications_docker_cleanup_failure_thread_id, + HighDiskUsage::class => $settings->telegram_notifications_server_disk_usage_thread_id, + Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, + Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, + ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, + TraefikVersionOutdated::class => $settings->telegram_notifications_traefik_outdated_thread_id, default => null, }; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php deleted file mode 100644 index f518cd2fdd..0000000000 --- a/app/Notifications/Container/ContainerStopped.php +++ /dev/null @@ -1,123 +0,0 @@ -onQueue('high'); - } - - public function via(object $notifiable): array - { - return $notifiable->getEnabledChannels('status_change'); - } - - public function toMail(): MailMessage - { - $mail = new MailMessage; - $mail->subject("Coolify: A resource has been stopped unexpectedly on {$this->server->name}"); - $mail->view('emails.container-stopped', [ - 'containerName' => $this->name, - 'serverName' => $this->server->name, - 'url' => $this->url, - ]); - - return $mail; - } - - public function toDiscord(): DiscordMessage - { - $message = new DiscordMessage( - title: ':cross_mark: Resource stopped', - description: "{$this->name} has been stopped unexpectedly on {$this->server->name}.", - color: DiscordMessage::errorColor(), - ); - - if ($this->url) { - $message->addField('Resource', '[Link]('.$this->url.')'); - } - - return $message; - } - - public function toTelegram(): array - { - $message = "Coolify: A resource ($this->name) has been stopped unexpectedly on {$this->server->name}"; - $payload = [ - 'message' => $message, - ]; - if ($this->url) { - $payload['buttons'] = [ - [ - [ - 'text' => 'Open Application in Coolify', - 'url' => $this->url, - ], - ], - ]; - } - - return $payload; - } - - public function toPushover(): PushoverMessage - { - $buttons = []; - if ($this->url) { - $buttons[] = [ - 'text' => 'Open Application in Coolify', - 'url' => $this->url, - ]; - } - - return new PushoverMessage( - title: 'Resource stopped', - level: 'error', - message: "A resource ({$this->name}) has been stopped unexpectedly on {$this->server->name}", - buttons: $buttons, - ); - } - - public function toSlack(): SlackMessage - { - $title = 'Resource stopped'; - $description = "A resource ({$this->name}) has been stopped unexpectedly on {$this->server->name}"; - - if ($this->url) { - $description .= "\n*Resource URL:* {$this->url}"; - } - - return new SlackMessage( - title: $title, - description: $description, - color: SlackMessage::errorColor() - ); - } - - public function toWebhook(): array - { - $data = [ - 'success' => false, - 'message' => 'Resource stopped unexpectedly', - 'event' => 'container_stopped', - 'container_name' => $this->name, - 'server_name' => $this->server->name, - 'server_uuid' => $this->server->uuid, - ]; - - if ($this->url) { - $data['url'] = $this->url; - } - - return $data; - } -} diff --git a/app/Notifications/Database/BackupMissing.php b/app/Notifications/Database/BackupMissing.php new file mode 100644 index 0000000000..d7f127ce3c --- /dev/null +++ b/app/Notifications/Database/BackupMissing.php @@ -0,0 +1,83 @@ +onQueue('high'); + $this->databaseName = $backup->database?->name ?? $backup->description ?? $backup->uuid; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function toMail(): MailMessage + { + return (new MailMessage) + ->subject("Coolify: [ACTION REQUIRED] No recent backup for {$this->databaseName}") + ->view('emails.backup-missing', $this->messageData()); + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':warning: Scheduled database backup missing', + description: $this->description(), + color: DiscordMessage::errorColor(), + isCritical: true, + ); + } + + public function toTelegram(): array + { + return ['message' => 'Coolify: '.$this->description()]; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage(title: 'Scheduled database backup missing', level: 'error', message: $this->description()); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage(title: 'Scheduled database backup missing', description: $this->description(), color: SlackMessage::errorColor()); + } + + public function toWebhook(): array + { + return array_merge($this->messageData(), [ + 'success' => false, + 'message' => 'Scheduled database backup missing', + 'event' => 'backup_missing', + 'backup_uuid' => $this->backup->uuid, + ]); + } + + private function description(): string + { + return "The enabled backup schedule for {$this->databaseName} has produced no executions in the last {$this->backup->missing_backup_notification_days} day(s)."; + } + + private function messageData(): array + { + return [ + 'database_name' => $this->databaseName, + 'days' => $this->backup->missing_backup_notification_days, + 'last_execution_at' => $this->lastExecutionAt?->toDateTimeString(), + ]; + } +} diff --git a/app/Notifications/Internal/GeneralNotification.php b/app/Notifications/Internal/GeneralNotification.php index 1d23672100..52e986ed6e 100644 --- a/app/Notifications/Internal/GeneralNotification.php +++ b/app/Notifications/Internal/GeneralNotification.php @@ -58,4 +58,14 @@ class GeneralNotification extends Notification implements ShouldQueue color: SlackMessage::infoColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => $this->message, + 'event' => 'general', + 'url' => base_url(), + ]; + } } diff --git a/app/Notifications/Notification.php b/app/Notifications/Notification.php deleted file mode 100644 index d37716a8b4..0000000000 --- a/app/Notifications/Notification.php +++ /dev/null @@ -1,22 +0,0 @@ -onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 58c959bd8d..2978eaed32 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -2,6 +2,7 @@ namespace App\Notifications\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; @@ -16,10 +17,10 @@ class TaskSuccess extends CustomEmailNotification public function __construct(public ScheduledTask $task, public string $output) { $this->onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 4b56f5860b..2d2ebabaf0 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -74,4 +74,16 @@ class ForceDisabled extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "Server ({$this->server->name}) disabled because it is not paid! All automations and integrations are stopped.", + 'event' => 'server_force_disabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60f..61022d36b5 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -65,4 +65,16 @@ class ForceEnabled extends CustomEmailNotification color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => "Server ({$this->server->name}) enabled again!", + 'event' => 'server_force_enabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054b..6c2712b68d 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -17,8 +17,7 @@ class HetznerDeletionFailed extends CustomEmailNotification public function via(object $notifiable): array { - - return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + return $notifiable->getEnabledChannels('hetzner_deletion_failure'); } public function toMail(): MailMessage @@ -66,4 +65,16 @@ class HetznerDeletionFailed extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud. The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.", + 'event' => 'hetzner_deletion_failed', + 'hetzner_server_id' => $this->hetznerServerId, + 'error' => $this->errorMessage, + 'url' => base_url().'/servers', + ]; + } } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9c..8d2a5e3952 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -7,7 +7,6 @@ use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Collection; -use Spatie\Url\Url; class SslExpirationNotification extends CustomEmailNotification { @@ -19,39 +18,9 @@ class SslExpirationNotification extends CustomEmailNotification { $this->onQueue('high'); $this->resources = collect($resources); - - // Collect URLs for each resource - $this->resources->each(function ($resource) { - if (data_get($resource, 'environment.project.uuid')) { - $routeName = match ($resource->type()) { - 'application' => 'project.application.configuration', - 'database' => 'project.database.configuration', - 'service' => 'project.service.configuration', - default => null - }; - - if ($routeName) { - $route = route($routeName, [ - 'project_uuid' => data_get($resource, 'environment.project.uuid'), - 'environment_uuid' => data_get($resource, 'environment.uuid'), - $resource->type().'_uuid' => data_get($resource, 'uuid'), - ]); - - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - $this->urls[$resource->name] = $url->__toString(); - } else { - $this->urls[$resource->name] = $route; - } - } - } - }); + $this->urls = $this->resources->mapWithKeys(fn ($resource) => [ + $resource->name => base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/database/{$resource->uuid}", + ])->all(); } public function via(object $notifiable): array @@ -148,4 +117,18 @@ class SslExpirationNotification extends CustomEmailNotification color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $resourceNames = $this->resources->pluck('name'); + + return [ + 'success' => false, + 'message' => "SSL certificates have been renewed for: {$resourceNames->join(', ')}. These resources need to be redeployed manually for the new SSL certificates to take effect.", + 'event' => 'ssl_certificate_renewal', + 'resources' => $resourceNames->values()->all(), + 'urls' => $this->urls, + 'url' => base_url(), + ]; + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 9163d595cd..14b90c2c59 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,7 +2,14 @@ namespace App\Providers; +use Illuminate\Auth\Events\Failed; +use Illuminate\Auth\Events\Login; +use Illuminate\Auth\Events\Logout; +use Illuminate\Auth\Events\PasswordReset; +use Illuminate\Auth\Events\Registered; +use Illuminate\Auth\Events\Verified; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Illuminate\Support\Facades\Event; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; use SocialiteProviders\Clerk\ClerkExtendSocialite; @@ -28,7 +35,39 @@ class EventServiceProvider extends ServiceProvider public function boot(): void { - // + Event::listen(Login::class, function (Login $event): void { + auditLog('auth.user.login_succeeded', $this->authContext($event->user)); + }); + Event::listen(Failed::class, function (Failed $event): void { + auditLog('auth.user.login_failed', [ + 'attempted_email' => data_get($event->credentials, 'email'), + 'guard' => $event->guard, + ], 'warning'); + }); + Event::listen(Logout::class, function (Logout $event): void { + auditLog('auth.user.logged_out', $this->authContext($event->user)); + }); + Event::listen(Registered::class, function (Registered $event): void { + auditLog('auth.user.registered', $this->authContext($event->user)); + }); + Event::listen(Verified::class, function (Verified $event): void { + auditLog('auth.user.email_verified', $this->authContext($event->user)); + }); + Event::listen(PasswordReset::class, function (PasswordReset $event): void { + auditLog('auth.user.password_reset', $this->authContext($event->user)); + }); + } + + private function authContext(?object $user): array + { + return [ + 'team_id' => $user?->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user?->name, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, + ]; } public function shouldDiscoverEvents(): bool diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index dfa3bb3314..6426860187 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -9,10 +9,8 @@ use App\Actions\Fortify\UpdateUserProfileInformation; use App\Models\OauthSetting; use App\Models\TeamInvitation; use App\Models\User; -use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Laravel\Fortify\Contracts\RegisterResponse; use Laravel\Fortify\Fortify; @@ -92,14 +90,19 @@ class FortifyServiceProvider extends ServiceProvider } $user->currentTeam = $invitation->team; $invitation->delete(); + session(['currentTeam' => $user->currentTeam]); } else { - // Normal login - use personal team - $user->currentTeam = $user->teams->firstWhere('personal_team', true); - if (! $user->currentTeam) { - $user->currentTeam = $user->recreate_personal_team(); + // Restore the last active team; only fall back when unambiguous. + $team = $user->resolveStoredTeam(); + if (! $team && $user->teams->isEmpty()) { + $team = $user->recreate_personal_team(); } + if ($team) { + session(['currentTeam' => $user->currentTeam = $team]); + } + // Otherwise (multiple teams, no stored choice) leave the session + // team unset so the user is sent to the team-selection screen. } - session(['currentTeam' => $user->currentTeam]); return $user; } @@ -122,45 +125,5 @@ class FortifyServiceProvider extends ServiceProvider Fortify::twoFactorChallengeView(function () { return view('auth.two-factor-challenge'); }); - - RateLimiter::for('force-password-reset', function (Request $request) { - return Limit::perMinute(15)->by($request->user()->id); - }); - - RateLimiter::for('forgot-password', function (Request $request) { - // Use real client IP (not spoofable forwarded headers) - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - - $limits = [ - Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)), - ]; - - $emailIdentity = normalize_email_identity($request->input('email')); - if ($emailIdentity !== null) { - $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); - } - - return $limits; - }); - - RateLimiter::for('login', function (Request $request) { - $email = (string) $request->email; - // Use email + real client IP (not spoofable forwarded headers) - // server('REMOTE_ADDR') gives the actual connecting IP before proxy headers - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - - return Limit::perMinute(5)->by($email.'|'.$realIp); - }); - - RateLimiter::for('magic-link', function (Request $request) { - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - $token = (string) $request->input('token'); - - return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp)); - }); - - RateLimiter::for('two-factor', function (Request $request) { - return Limit::perMinute(5)->by($request->session()->get('login.id')); - }); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 4068572c81..79139c6b93 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -58,5 +58,34 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('feedback', function (Request $request) { return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by((string) $request->email.'|'.auth_rate_limit_ip($request)); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + + RateLimiter::for('forgot-password', function (Request $request) { + $limits = [ + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))), + ]; + + $emailIdentity = normalize_email_identity($request->input('email')); + if ($emailIdentity !== null) { + $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); + } + + return $limits; + }); + + RateLimiter::for('magic-link', function (Request $request) { + return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.auth_rate_limit_ip($request))); + }); + + RateLimiter::for('force-password-reset', function (Request $request) { + return Limit::perMinute(15)->by($request->user()->id); + }); } } diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index ba1aed11b6..29e219bd35 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -77,15 +77,16 @@ class ValidGitRepositoryUrl implements ValidationRule } // Validate based on URL type - if (str_starts_with($value, 'git@')) { + if (preg_match('/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:/', $value)) { if (! $this->allowSSH) { $fail('SSH URLs are not allowed.'); return; } - // Validate SSH URL format (git@host:user/repo.git) - if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) { + // Validate scp-style SSH URL format (user@host:user/repo.git) + $scp = parseScpStyleGitUrl($value); + if ($scp === null || preg_match('/^[a-zA-Z0-9.-]+$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { $fail('The :attribute is not a valid SSH repository URL.'); return; @@ -149,7 +150,7 @@ class ValidGitRepositoryUrl implements ValidationRule return; } } else { - $fail('The :attribute must start with https://, http://, git://, or git@.'); + $fail('The :attribute must start with https://, http://, git://, or be an SSH URL (user@host:path).'); return; } diff --git a/app/Rules/ValidS3BucketName.php b/app/Rules/ValidS3BucketName.php index bcfa430efe..cadddd3b6d 100644 --- a/app/Rules/ValidS3BucketName.php +++ b/app/Rules/ValidS3BucketName.php @@ -17,7 +17,7 @@ class ValidS3BucketName implements ValidationRule public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value) || ! ValidationPatterns::isValidS3BucketName($value)) { - $fail('The :attribute must be a valid S3 bucket name: 3-63 lowercase letters, numbers, dots, or hyphens; start and end with a letter or number; no consecutive dots, dot-hyphen pairs, or IP address format.'); + $fail('The :attribute must be a valid S3 bucket name: 3-63 letters, numbers, dots, or hyphens; start and end with a letter or number; no consecutive dots, dot-hyphen pairs, or IP address format.'); } } } diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index 2ec8f88e3e..32524c7fa6 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged; use Symfony\Component\HttpKernel\Exception\HttpException; class OauthLoginService @@ -27,13 +28,39 @@ class OauthLoginService ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); - Auth::login($user); $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); session(['currentTeam' => $user->currentTeam = $team]); + if ($this->requiresTwoFactorChallenge($user)) { + Auth::logout(); + session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => false, + ]); + TwoFactorAuthenticationChallenged::dispatch($user); + + return $user; + } + + Auth::login($user); + auditLog('auth.user.oauth_login_succeeded', [ + 'team_id' => $team?->id, + 'resource' => 'user', + 'user_name' => $user->name, + 'actor_id' => $user->id, + 'actor_name' => $user->name, + 'actor_email' => $user->email, + 'provider' => $provider, + ]); + return $user; } + public function requiresTwoFactorChallenge(User $user): bool + { + return $user->hasEnabledTwoFactorAuthentication(); + } + private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User { $provider = $oauthSetting->provider; @@ -67,7 +94,15 @@ class OauthLoginService return $identity->user; } + if (! $this->hasVerifiedEmail($provider, $rawClaims)) { + throw new HttpException(403, 'OAuth provider did not verify the email address'); + } + $user = User::whereEmail($email)->first(); + if ($user?->oauthIdentities()->exists()) { + throw new HttpException(403, 'OAuth identity cannot be linked to this account'); + } + if (! $user) { if (! $this->canCreateUser($oauthSetting)) { throw new HttpException(403, 'Registration is disabled'); @@ -93,6 +128,23 @@ class OauthLoginService } } + /** + * GitHub and Bitbucket select only verified primary email addresses in + * their Socialite providers. Other providers must return an explicit + * boolean verification claim in the raw provider response. + * + * @param array $rawClaims + */ + private function hasVerifiedEmail(string $provider, array $rawClaims): bool + { + return match ($provider) { + 'github', 'bitbucket' => true, + 'discord' => data_get($rawClaims, 'verified') === true, + 'google' => data_get($rawClaims, 'verified_email') === true, + default => data_get($rawClaims, 'email_verified') === true, + }; + } + private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User { $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) @@ -144,6 +196,10 @@ class OauthLoginService throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); } + if ($user?->oauthIdentities()->exists()) { + throw new HttpException(403, 'OAuth identity cannot be linked to this account'); + } + if (! $user) { if (! $this->canCreateUser($oauthSetting)) { throw new HttpException(403, 'Registration is disabled'); diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 8859a99809..3a59ad58fa 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -18,14 +18,13 @@ use Illuminate\Support\Facades\Log; * State Priority (highest to lowest): * 1. Degraded (from sub-resources) → degraded:unhealthy * 2. Restarting → degraded:unhealthy (or restarting:unknown if preserveRestarting=true) - * 3. Crash Loop (exited with restarts) → degraded:unhealthy - * 4. Mixed (running + exited) → degraded:unhealthy - * 5. Mixed (running + starting) → starting:unknown - * 6. Running → running:healthy/unhealthy/unknown - * 7. Dead/Removing → degraded:unhealthy - * 8. Paused → paused:unknown - * 9. Starting/Created → starting:unknown - * 10. Exited → exited + * 3. Mixed (running + exited) → degraded:unhealthy + * 4. Mixed (running + starting) → starting:unknown + * 5. Running → running:healthy/unhealthy/unknown + * 6. Dead/Removing → degraded:unhealthy + * 7. Paused → paused:unknown + * 8. Starting/Created → starting:unknown + * 9. Exited → exited * * The $preserveRestarting parameter controls whether "restarting" containers should be * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default). @@ -228,23 +227,18 @@ class ContainerStatusAggregator return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy'; } - // Priority 3: Crash loop detection (exited with restart count > 0) - if ($hasExited && $maxRestartCount > 0) { - return 'degraded:unhealthy'; - } - - // Priority 4: Mixed state (some running, some exited = degraded) + // Priority 3: Mixed state (some running, some exited = degraded) if ($hasRunning && $hasExited) { return 'degraded:unhealthy'; } - // Priority 5: Mixed state (some running, some starting = still starting) + // Priority 4: Mixed state (some running, some starting = still starting) // If any component is still starting, the entire service stack is not fully ready if ($hasRunning && $hasStarting) { return 'starting:unknown'; } - // Priority 6: Running containers (check health status) + // Priority 5: Running containers (check health status) if ($hasRunning) { if ($hasUnhealthy) { return 'running:unhealthy'; @@ -255,22 +249,22 @@ class ContainerStatusAggregator } } - // Priority 7: Dead or removing containers + // Priority 6: Dead or removing containers if ($hasDead) { return 'degraded:unhealthy'; } - // Priority 8: Paused containers + // Priority 7: Paused containers if ($hasPaused) { return 'paused:unknown'; } - // Priority 9: Starting/created containers + // Priority 8: Starting/created containers if ($hasStarting) { return 'starting:unknown'; } - // Priority 10: All containers exited (no restart count = truly stopped) + // Priority 9: All containers exited return 'exited'; } } diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 386bdd5bb9..184aa01eb3 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -7,6 +7,7 @@ use App\Models\EnvironmentVariable; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; +use App\Support\DomainPortOverrides; use Illuminate\Support\Arr; class ApplicationConfigurationSnapshot @@ -169,6 +170,7 @@ class ApplicationConfigurationSnapshot $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'), $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'), $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'), + $this->item('custom_container_name_prefix', 'Container name prefix', data_get($this->application, 'settings.custom_container_name_prefix'), 'redeploy'), $this->item('is_consistent_container_name_enabled', 'Consistent container name', data_get($this->application, 'settings.is_consistent_container_name_enabled'), 'redeploy'), $this->item('is_container_label_escape_enabled', 'Escape container labels', data_get($this->application, 'settings.is_container_label_escape_enabled'), 'redeploy'), $this->item('is_container_label_readonly_enabled', 'Read-only container labels', data_get($this->application, 'settings.is_container_label_readonly_enabled'), 'redeploy'), @@ -194,6 +196,7 @@ class ApplicationConfigurationSnapshot { return [ $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('domain_port_overrides', 'Domain port overrides', DomainPortOverrides::sorted($this->application->domain_port_overrides), 'redeploy'), $this->item('noindex_domains', 'Search engine indexing', $this->application->noindexDomains()->all(), 'redeploy'), $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php new file mode 100644 index 0000000000..1c6e694b7e --- /dev/null +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -0,0 +1,214 @@ +> */ + private array $zoneCache = []; + + public function syncZones(IntegrationToken $token): int + { + unset($this->zoneCache[$token->team_id]); + $zones = []; + $page = 1; + do { + $response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]); + if (! $response->successful() || $response->json('success') !== true) { + throw new RuntimeException('Cloudflare zones could not be synchronized.'); + } + array_push($zones, ...$response->json('result', [])); + $totalPages = max(1, (int) $response->json('result_info.total_pages', 1)); + $page++; + } while ($page <= $totalPages); + + DB::transaction(function () use ($token, $zones): void { + $ids = []; + foreach ($zones as $zone) { + $ids[] = $zone['id']; + $token->dnsZones()->updateOrCreate(['provider_zone_id' => $zone['id']], [ + 'name' => strtolower($zone['name']), 'account_id' => data_get($zone, 'account.id'), + 'account_name' => data_get($zone, 'account.name'), + ]); + } + $token->dnsZones()->whereNotIn('provider_zone_id', $ids)->whereDoesntHave('managedRecords')->delete(); + $metadata = $token->metadata ?? []; + $metadata['zones_synced_at'] = now()->toIso8601String(); + $token->update(['metadata' => $metadata]); + }); + + return count($zones); + } + + /** @return Collection */ + public function findZones(int $teamId, string $hostname): Collection + { + $hostname = strtolower(rtrim($hostname, '.')); + $matches = $this->zonesForTeam($teamId)->filter( + fn (DnsProviderZone $zone) => $hostname === $zone->name || str_ends_with($hostname, '.'.$zone->name) + ); + $longest = $matches->max(fn (DnsProviderZone $zone) => strlen($zone->name)); + + return $matches->filter(fn (DnsProviderZone $zone) => strlen($zone->name) === $longest)->values(); + } + + /** @return Collection */ + private function zonesForTeam(int $teamId): Collection + { + return $this->zoneCache[$teamId] ??= DnsProviderZone::query() + ->whereHas('integrationToken', fn ($query) => $query->where('team_id', $teamId)->where('provider', 'cloudflare')) + ->with('integrationToken') + ->get(); + } + + /** + * @return array{id: string, type: string, name: string, content: string}|null + */ + public function findRecord(DnsProviderZone $zone, string $hostname, string $type): ?array + { + $hostname = strtolower(rtrim($hostname, '.')); + $response = $this->client($zone->integrationToken)->get( + "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", + ['type' => $type, 'name' => $hostname, 'per_page' => 100], + ); + if (! $response->successful()) { + throw new RuntimeException('Cloudflare DNS records could not be checked.'); + } + $remote = collect($response->json('result', []))->first(); + if ($remote === null) { + return null; + } + + return [ + 'id' => (string) ($remote['id'] ?? ''), + 'type' => (string) ($remote['type'] ?? $type), + 'name' => strtolower((string) ($remote['name'] ?? $hostname)), + 'content' => (string) ($remote['content'] ?? ''), + ]; + } + + public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null): ManagedDnsRecord + { + $hostname = strtolower(rtrim($hostname, '.')); + $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; + $remote = $this->findRecord($zone, $hostname, $type); + if ($remote !== null) { + if ($remote['content'] === $content) { + if ($remote['id'] === '') { + throw new RuntimeException('Cloudflare DNS records could not be checked.'); + } + + $record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + $this->auditDnsRecord('created', $zone, $hostname, $resource); + + return $record; + } + throw new DnsRecordConflictException($remote['id'], $remote['content'], $content); + } + $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ + 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false, + ]); + if (! $response->successful() || ! is_string($response->json('result.id'))) { + throw new RuntimeException('Cloudflare could not create the DNS record.'); + } + + $record = $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource); + $this->auditDnsRecord('created', $zone, $hostname, $resource); + + return $record; + } + + public function replaceRecord( + DnsProviderZone $zone, + string $recordId, + string $hostname, + string $content, + ?Model $resource = null, + ?string $expectedCurrent = null, + ): ManagedDnsRecord { + $hostname = strtolower(rtrim($hostname, '.')); + $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; + $remote = $this->findRecord($zone, $hostname, $type); + if ($remote === null + || $remote['id'] === '' + || $remote['id'] !== $recordId + || ($expectedCurrent !== null && $remote['content'] !== $expectedCurrent) + || $remote['name'] !== $hostname) { + throw new RuntimeException('The DNS conflict is no longer available. Check the record again.'); + } + + $response = $this->client($zone->integrationToken)->put( + "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}", + ['type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false], + ); + if (! $response->successful()) { + throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.'); + } + + $record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + $this->auditDnsRecord('replaced', $zone, $hostname, $resource); + + return $record; + } + + public function deleteRecord(ManagedDnsRecord $record): bool + { + $record->loadMissing(['zone', 'integrationToken']); + $url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}"; + $response = $this->client($record->integrationToken)->get($url); + $remote = $response->json('result'); + if (! $response->successful() || ($remote['type'] ?? null) !== $record->type + || strtolower((string) ($remote['name'] ?? '')) !== $record->name || ($remote['content'] ?? null) !== $record->content) { + return false; + } + if (! $this->client($record->integrationToken)->delete($url)->successful()) { + return false; + } + $record->delete(); + $this->auditDnsRecord('deleted', $record->zone, $record->name, $record->resource); + + return true; + } + + private function trackRecord(DnsProviderZone $zone, string $recordId, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord + { + return ManagedDnsRecord::query()->updateOrCreate( + ['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $recordId], + ['team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id, + 'resource_type' => $resource?->getMorphClass(), 'resource_id' => $resource?->getKey(), + 'type' => $type, 'name' => $name, 'content' => $content], + ); + } + + private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void + { + $resourceType = $resource ? str(class_basename($resource))->snake()->value() : 'dns_record'; + + $source = auth()->check() ? 'ui' : 'system'; + auditLog("{$source}.dns_record.{$action}", [ + 'team_id' => $zone->integrationToken->team_id, + 'resource' => $resourceType, + "{$resourceType}_uuid" => $resource?->getAttribute('uuid'), + "{$resourceType}_name" => $resource?->getAttribute('name'), + 'hostname' => $hostname, + 'provider' => 'cloudflare', + 'zone' => $zone->name, + ]); + } + + private function client(IntegrationToken $token): PendingRequest + { + return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(10); + } +} diff --git a/app/Services/ProxyPortParser.php b/app/Services/ProxyPortParser.php new file mode 100644 index 0000000000..f6e0dbfc39 --- /dev/null +++ b/app/Services/ProxyPortParser.php @@ -0,0 +1,152 @@ + + */ + public static function fromConfiguration(string $configuration): array + { + try { + $parsed = Yaml::parse($configuration); + } catch (ParseException $exception) { + throw new \InvalidArgumentException('The proxy configuration must contain valid YAML.', previous: $exception); + } + + if (! is_array($parsed)) { + return []; + } + + $ports = []; + + foreach (['traefik', 'caddy'] as $proxyService) { + $path = "services.{$proxyService}.ports"; + + if (! data_has($parsed, $path)) { + continue; + } + + $configuredPorts = data_get($parsed, $path); + if (! is_array($configuredPorts) || ! array_is_list($configuredPorts)) { + self::invalid(); + } + + foreach ($configuredPorts as $configuredPort) { + $ports[] = self::publishedPort($configuredPort); + } + } + + return array_values(array_unique($ports)); + } + + private static function publishedPort(mixed $configuredPort): int + { + if (is_array($configuredPort)) { + if (array_is_list($configuredPort) || ! array_key_exists('target', $configuredPort)) { + self::invalid(); + } + + self::validateProtocol($configuredPort['protocol'] ?? null); + if (array_key_exists('host_ip', $configuredPort)) { + self::validateHostIp($configuredPort['host_ip']); + } + $target = self::portNumber($configuredPort['target']); + + return array_key_exists('published', $configuredPort) + ? self::portNumber($configuredPort['published']) + : $target; + } + + if (! is_int($configuredPort) && ! is_string($configuredPort)) { + self::invalid(); + } + + if (is_int($configuredPort)) { + return self::portNumber($configuredPort); + } + + $portDefinition = $configuredPort; + $protocolSeparator = strrpos($portDefinition, '/'); + if ($protocolSeparator !== false) { + self::validateProtocol(substr($portDefinition, $protocolSeparator + 1)); + $portDefinition = substr($portDefinition, 0, $protocolSeparator); + } + + if (str_starts_with($portDefinition, '[')) { + if (! preg_match('/^\[([^]]+)]:(\d+):(\d+)$/D', $portDefinition, $matches)) { + self::invalid(); + } + + self::validateHostIp($matches[1]); + self::portNumber($matches[3]); + + return self::portNumber($matches[2]); + } + + $parts = explode(':', $portDefinition); + if (count($parts) < 1 || count($parts) > 3) { + self::invalid(); + } + + if (count($parts) === 3 && $parts[0] === '') { + self::invalid(); + } + + if (count($parts) === 3) { + self::validateHostIp($parts[0]); + } + + $portParts = count($parts) === 3 ? array_slice($parts, 1) : $parts; + foreach ($portParts as $part) { + self::portNumber($part); + } + + return self::portNumber($portParts[0]); + } + + private static function portNumber(mixed $port): int + { + if (is_int($port)) { + if ($port < 1 || $port > 65535) { + self::invalid(); + } + + return $port; + } + + if (! is_string($port) || preg_match('/^\d+$/D', $port) !== 1) { + self::invalid(); + } + + $normalized = (int) $port; + if ($normalized < 1 || $normalized > 65535) { + self::invalid(); + } + + return $normalized; + } + + private static function validateProtocol(mixed $protocol): void + { + if ($protocol !== null && (! is_string($protocol) || ! in_array($protocol, ['tcp', 'udp'], true))) { + self::invalid(); + } + } + + private static function validateHostIp(mixed $hostIp): void + { + if (! is_string($hostIp) || filter_var($hostIp, FILTER_VALIDATE_IP) === false) { + self::invalid(); + } + } + + private static function invalid(): never + { + throw new \InvalidArgumentException('Proxy ports must be integers from 1 through 65535.'); + } +} diff --git a/app/Services/RestartCountTracker.php b/app/Services/RestartCountTracker.php new file mode 100644 index 0000000000..e67deacb3c --- /dev/null +++ b/app/Services/RestartCountTracker.php @@ -0,0 +1,31 @@ + $previousRestartCount; + $restartCountChanged = $newGeneration || $restartCountIncreased; + + $restartLimitReached = $maxRestartCount > 0 + && $observedRestartCount >= $maxRestartCount; + + return [ + 'restart_count' => $restartCountChanged ? $observedRestartCount : $previousRestartCount, + 'restart_count_changed' => $restartCountChanged, + 'restart_limit_reached' => $restartLimitReached, + 'new_generation' => $newGeneration, + ]; + } +} diff --git a/app/Services/ScheduledJobDeliveryService.php b/app/Services/ScheduledJobDeliveryService.php new file mode 100644 index 0000000000..0bd4c031c5 --- /dev/null +++ b/app/Services/ScheduledJobDeliveryService.php @@ -0,0 +1,232 @@ +copy()->setTimezone($timezone); + $cron = new CronExpression(VALID_CRON_STRINGS[$frequency] ?? $frequency); + $scheduledFor = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + if (! $scheduledFor->gte($executionTime->copy()->subMinutes(self::CATCH_UP_WINDOW_MINUTES))) { + return false; + } + + $delivery = DB::transaction(function () use ($scheduleKey, $scheduledFor, $jobType, $resourceId, $payload): ?ScheduledJobDelivery { + ScheduledJobState::query()->insertOrIgnore([ + 'uuid' => new_public_id(), + 'schedule_key' => $scheduleKey, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $state = ScheduledJobState::query() + ->where('schedule_key', $scheduleKey) + ->lockForUpdate() + ->firstOrFail(); + + if ($state->last_scheduled_for?->gte($scheduledFor)) { + return null; + } + + $state->update(['last_scheduled_for' => $scheduledFor->utc()]); + + return ScheduledJobDelivery::create([ + 'schedule_key' => $scheduleKey, + 'scheduled_for' => $scheduledFor->utc(), + 'job_type' => $jobType, + 'resource_id' => $resourceId, + 'payload' => $payload, + 'status' => 'pending', + ]); + }); + + if ($delivery === null) { + return false; + } + + return $this->publish($delivery); + } + + public function recordSkipped( + string $scheduleKey, + string $frequency, + string $timezone, + ?Carbon $executionTime = null, + ): bool { + $executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone); + $cron = new CronExpression(VALID_CRON_STRINGS[$frequency] ?? $frequency); + $scheduledFor = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + if (! $scheduledFor->gte($executionTime->copy()->subMinutes(self::CATCH_UP_WINDOW_MINUTES))) { + return false; + } + + return DB::transaction(function () use ($scheduleKey, $scheduledFor): bool { + ScheduledJobState::query()->insertOrIgnore([ + 'uuid' => new_public_id(), + 'schedule_key' => $scheduleKey, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $state = ScheduledJobState::query() + ->where('schedule_key', $scheduleKey) + ->lockForUpdate() + ->firstOrFail(); + + if ($state->last_scheduled_for?->gte($scheduledFor)) { + return false; + } + + $state->update(['last_scheduled_for' => $scheduledFor->utc()]); + + return true; + }); + } + + public function publishPending(): void + { + ScheduledJobDelivery::query() + ->where('status', 'pending') + ->orderBy('id') + ->chunkById(100, function ($occurrences): void { + foreach ($occurrences as $occurrence) { + $this->publish($occurrence); + } + }); + } + + public function deleteOldOccurrences(): void + { + ScheduledJobDelivery::query() + ->where('status', 'claimed') + ->where('updated_at', '<', now()->subDays(2)) + ->update([ + 'status' => 'failed', + 'updated_at' => now(), + ]); + + ScheduledJobDelivery::query() + ->whereIn('status', ['failed', 'skipped']) + ->where('created_at', '<', now()->subDays(30)) + ->chunkById(100, function ($occurrences): void { + ScheduledJobDelivery::query()->whereKey($occurrences->modelKeys())->delete(); + }); + } + + public function claim(string $uuid, string $claimToken): bool + { + $claimed = ScheduledJobDelivery::query() + ->where('uuid', $uuid) + ->whereIn('status', ['pending', 'enqueued']) + ->update([ + 'status' => 'claimed', + 'claim_token' => $claimToken, + 'started_at' => now(), + 'updated_at' => now(), + ]); + + if ($claimed === 1) { + return true; + } + + return ScheduledJobDelivery::query() + ->where('uuid', $uuid) + ->where('status', 'claimed') + ->where('claim_token', $claimToken) + ->exists(); + } + + public function complete(string $uuid, string $claimToken): void + { + ScheduledJobDelivery::query() + ->where('uuid', $uuid) + ->where('claim_token', $claimToken) + ->delete(); + } + + public function fail(string $uuid, string $claimToken): void + { + ScheduledJobDelivery::query() + ->where('uuid', $uuid) + ->where('claim_token', $claimToken) + ->update([ + 'status' => 'failed', + 'updated_at' => now(), + ]); + } + + private function publish(ScheduledJobDelivery $occurrence): bool + { + if ($occurrence->status !== 'pending') { + return false; + } + + $job = match ($occurrence->job_type) { + 'scheduled-task' => ($task = ScheduledTask::find($occurrence->resource_id)) + ? new ScheduledTaskJob($task, $occurrence->uuid) + : null, + 'database-backup' => ($backup = ScheduledDatabaseBackup::find($occurrence->resource_id)) + ? new DatabaseBackupJob($backup, $occurrence->uuid) + : null, + 'volume-backup' => ($backup = ScheduledVolumeBackup::find($occurrence->resource_id)) + ? new VolumeBackupJob($backup, $occurrence->uuid) + : null, + 'docker-cleanup' => ($server = Server::find($occurrence->resource_id)) + ? new DockerCleanupJob( + $server, + false, + data_get($occurrence->payload, 'delete_unused_volumes', false), + data_get($occurrence->payload, 'delete_unused_networks', false), + $occurrence->uuid, + ) + : null, + default => null, + }; + + if ($job === null) { + ScheduledJobDelivery::query()->whereKey($occurrence->id)->update(['status' => 'skipped']); + + return false; + } + + dispatch($job); + + ScheduledJobDelivery::query() + ->whereKey($occurrence->id) + ->where('status', 'pending') + ->update([ + 'status' => 'enqueued', + 'enqueued_at' => now(), + 'updated_at' => now(), + ]); + + return true; + } +} diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php deleted file mode 100644 index 6e29851dfc..0000000000 --- a/app/Services/SchedulerLogParser.php +++ /dev/null @@ -1,188 +0,0 @@ - - */ - public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection - { - $logFiles = $this->getLogFiles(); - - $skips = collect(); - - foreach ($logFiles as $logFile) { - $lines = $this->readLastLines($logFile, 2000); - - foreach ($lines as $line) { - $entry = $this->parseLogLine($line); - if ($entry === null || ! isset($entry['context']['skip_reason'])) { - continue; - } - - if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) { - continue; - } - - $skips->push([ - 'timestamp' => $entry['timestamp'], - 'type' => $entry['context']['type'] ?? 'unknown', - 'reason' => $entry['context']['skip_reason'], - 'team_id' => $entry['context']['team_id'] ?? null, - 'context' => $entry['context'], - ]); - } - } - - return $skips->sortByDesc('timestamp')->values()->take($limit); - } - - /** - * Get recent manager execution logs (start/complete events). - * - * @return Collection - */ - public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection - { - $logFiles = $this->getLogFiles(); - - $runs = collect(); - - foreach ($logFiles as $logFile) { - $lines = $this->readLastLines($logFile, 2000); - - foreach ($lines as $line) { - $entry = $this->parseLogLine($line); - if ($entry === null) { - continue; - } - - if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) { - continue; - } - - $runs->push([ - 'timestamp' => $entry['timestamp'], - 'message' => $entry['message'], - 'duration_ms' => $entry['context']['duration_ms'] ?? null, - 'dispatched' => $entry['context']['dispatched'] ?? null, - 'skipped' => $entry['context']['skipped'] ?? null, - ]); - } - } - - return $runs->sortByDesc('timestamp')->values()->take($limit); - } - - private function getLogFiles(): array - { - $logDir = storage_path('logs'); - if (! File::isDirectory($logDir)) { - return []; - } - - $files = File::glob($logDir.'/scheduled-*.log'); - - // Sort by modification time, newest first - usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); - - // Only check last 3 days of logs - return array_slice($files, 0, 3); - } - - /** - * @return array{timestamp: string, level: string, message: string, context: array}|null - */ - private function parseLogLine(string $line): ?array - { - // Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"} - if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) { - return null; - } - - $timestamp = $matches[1]; - $level = $matches[2]; - $rest = $matches[3]; - - // Extract JSON context if present - $context = []; - if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) { - $message = $contextMatches[1]; - $decoded = json_decode($contextMatches[2], true); - if (is_array($decoded)) { - $context = $decoded; - } - } else { - $message = $rest; - } - - return [ - 'timestamp' => $timestamp, - 'level' => $level, - 'message' => $message, - 'context' => $context, - ]; - } - - /** - * Efficiently read the last N lines of a file. - * - * @return string[] - */ - private function readLastLines(string $filePath, int $lines): array - { - if (! File::exists($filePath)) { - return []; - } - - $fileSize = File::size($filePath); - if ($fileSize === 0) { - return []; - } - - // For small files, read the whole thing - if ($fileSize < 1024 * 1024) { - $content = File::get($filePath); - - return array_filter(explode("\n", $content), fn ($line) => $line !== ''); - } - - // For large files, read from the end - $handle = fopen($filePath, 'r'); - if ($handle === false) { - return []; - } - - $result = []; - $chunkSize = 8192; - $buffer = ''; - $position = $fileSize; - - while ($position > 0 && count($result) < $lines) { - $readSize = min($chunkSize, $position); - $position -= $readSize; - fseek($handle, $position); - $buffer = fread($handle, $readSize).$buffer; - - $bufferLines = explode("\n", $buffer); - $buffer = array_shift($bufferLines); - - $result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result); - } - - if ($buffer !== '' && count($result) < $lines) { - array_unshift($result, $buffer); - } - - fclose($handle); - - return array_slice($result, -$lines); - } -} diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php new file mode 100644 index 0000000000..3cd7eabe6c --- /dev/null +++ b/app/Services/SentinelTrafficClient.php @@ -0,0 +1,484 @@ + */ + private const ALLOWED_DIMENSIONS = [ + 'status', 'method', 'country', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot', 'agent', 'ip', 'useragent', + ]; + + public function __construct(protected Server $server) {} + + // NOTE: Sentinel's traffic API expects `from`/`to` as ISO-8601 Zulu strings + // (e.g. "2024-01-14T10:00:00Z"), confirmed against sentinel/API.md. + public function overview(?string $appKey, string $from, string $to): TrafficOverviewData + { + $json = json_decode($this->raw($this->overviewUrl($appKey, $from, $to)), true) ?? []; + + return TrafficOverviewData::fromSentinel($json); + } + + /** + * Convert a UI range key (24h/7d/30d) into ISO-8601 Zulu from/to bounds. + * + * @return array{0: string, 1: string} + */ + public static function rangeWindow(string $range): array + { + $to = now(); + $from = match ($range) { + '7d' => now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; + } + + /** + * Slim shared fetch for a single application's overview over a UI range, so the + * General-page widget and the full analytics tab don't duplicate window + client calls. + */ + public function appOverview(string $appKey, string $range = '24h'): TrafficOverviewData + { + [$from, $to] = self::rangeWindow($range); + + return $this->overview($appKey, $from, $to); + } + + public function paths(?string $appKey, string $from, string $to, int $limit = 50): Collection + { + $rows = json_decode($this->raw($this->pathsUrl($appKey, $from, $to, $limit)), true) ?? []; + + return collect($rows)->map(fn ($r) => TrafficPathData::fromSentinel($r)); + } + + public function breakdown(?string $appKey, string $dimension, string $from, string $to, int $limit = 50): Collection + { + $rows = json_decode($this->raw($this->breakdownUrl($appKey, $dimension, $from, $to, $limit)), true) ?? []; + + return collect($rows)->map(fn ($r) => TrafficBreakdownData::fromSentinel($r)); + } + + /** + * Per-bucket status-class time series for the stacked-area chart. + * + * The series endpoints take a single `range` knob (24h/7d/30d) rather than + * from/to, and always return a fixed-length, zero-filled array when present. + * An older Sentinel without the route answers 404 (empty/non-array body); + * we return an empty collection in that case so callers can gracefully fall + * back to the donut instead of surfacing an error. + * + * @return Collection + */ + public function series(?string $appKey, string $range = '24h'): Collection + { + $rows = json_decode($this->raw($this->seriesUrl($appKey, $range)), true); + + if (! is_array($rows) || $rows === []) { + return collect(); + } + + return collect($rows)->map(fn ($r) => TrafficSeriesBucketData::fromSentinel($r)); + } + + public function apps(): array + { + return json_decode($this->raw($this->appsUrl()), true) ?? []; + } + + public function attribution(): ?string + { + $json = json_decode($this->raw($this->attributionUrl()), true) ?? []; + + return data_get($json, 'attribution'); + } + + /** + * Warm the 60s response cache for every endpoint the dashboard reads, in as few SSH + * round-trips as possible. Prefers Sentinel's aggregate `/traffic/dashboard` (one call + * that returns every shape, including the per-app leaderboard), and falls back to a + * single batched `docker exec` over the individual endpoints when that route is absent + * (older Sentinel). Best-effort: any failure leaves the per-call methods to fetch + * individually. Returns the recorded app uuids so the caller can warm the per-app + * overviews when the fallback path is taken. + * + * @param array $dimensions + * @return array + */ + public function prefetchServerWide(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit = 50, int $breakdownLimit = 50, int $appsLimit = 200): array + { + $bundle = $this->fetchDashboard($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit); + if ($bundle !== null) { + $this->seedFromDashboard($appKey, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle); + + if ($appKey !== null) { + return []; + } + + return array_values(array_filter( + array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? []), + fn ($uuid) => is_string($uuid) && $uuid !== '' + )); + } + + // Fallback for older Sentinel without /traffic/dashboard: batch the individual endpoints. + $urls = [ + $this->overviewUrl($appKey, $from, $to), + $this->pathsUrl($appKey, $from, $to, $pathLimit), + $this->seriesUrl($appKey, $range), + $this->attributionUrl(), + ]; + foreach ($dimensions as $dimension) { + $urls[] = $this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit); + } + // The per-application leaderboard only exists on the unfiltered view. + if ($appKey === null) { + $urls[] = $this->appsUrl(); + } + + $this->warm($urls); + + if ($appKey !== null) { + return []; + } + + return array_values(array_filter( + $this->apps(), + fn ($uuid) => is_string($uuid) && $uuid !== '' + )); + } + + /** + * Fetch Sentinel's aggregate dashboard bundle, or null when the route is absent (older + * Sentinel 404s) or the response isn't a real bundle. The bundle always carries an + * `overview` member — even for an empty range — so its presence distinguishes a genuine + * response from a stub/`{}`. + * + * @return array|null + */ + private function fetchDashboard(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): ?array + { + // Older Sentinel 404s this route. raw() throws on that (and doesn't cache the failure), + // so without a marker every refresh would re-probe over SSH before falling back to the + // batch. Remember the absence for the same 60s window as the data cache: at most one + // wasted probe per minute, and a Sentinel upgrade is picked up on the next window. + $absenceKey = 'traffic:dashboard-absent:'.$this->server->uuid; + if (Cache::get($absenceKey) === true) { + return null; + } + + try { + $decoded = json_decode($this->raw($this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit)), true); + } catch (\Throwable) { + Cache::put($absenceKey, true, 60); + + return null; + } + + if (! is_array($decoded) || ! array_key_exists('overview', $decoded)) { + Cache::put($absenceKey, true, 60); + + return null; + } + + return $decoded; + } + + /** + * Decompose the aggregate bundle back into the per-endpoint response cache, so the + * existing per-call methods (overview/paths/breakdown/series/attribution and each + * leaderboard app's overview) read it as a cache hit — the whole page from one fetch. + * + * @param array $dimensions + * @param array $bundle + */ + private function seedFromDashboard(?string $appKey, string $from, string $to, string $range, array $dimensions, int $pathLimit, int $breakdownLimit, array $bundle): void + { + $put = fn (string $url, $member) => Cache::put($this->cacheKey($url), json_encode($member), 60); + + $put($this->overviewUrl($appKey, $from, $to), $bundle['overview'] ?? []); + $put($this->pathsUrl($appKey, $from, $to, $pathLimit), $bundle['paths'] ?? []); + $put($this->seriesUrl($appKey, $range), $bundle['series'] ?? []); + $put($this->attributionUrl(), ['attribution' => $bundle['attribution'] ?? null]); + + $breakdowns = $bundle['breakdowns'] ?? []; + foreach ($dimensions as $dimension) { + $put($this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit), $breakdowns[$dimension] ?? []); + } + + foreach ($bundle['apps'] ?? [] as $app) { + $uuid = is_array($app) ? ($app['uuid'] ?? null) : null; + if (is_string($uuid) && $uuid !== '' && isset($app['overview'])) { + $put($this->overviewUrl($uuid, $from, $to), $app['overview']); + } + } + } + + /** + * Warm the per-app overview cache for the leaderboard in one batched exec. + * + * @param array $appKeys + */ + public function prefetchAppOverviews(array $appKeys, string $from, string $to): void + { + $urls = array_map(fn ($appKey) => $this->overviewUrl($appKey, $from, $to), $appKeys); + + $this->warm($urls); + } + + private function overviewUrl(?string $appKey, string $from, string $to): string + { + $path = $this->appScopedPath($appKey, 'overview'); + + return $this->url($path, ['from' => $from, 'to' => $to]); + } + + private function pathsUrl(?string $appKey, string $from, string $to, int $limit): string + { + $path = $this->appScopedPath($appKey, 'paths'); + + return $this->url($path, ['from' => $from, 'to' => $to, 'limit' => (int) $limit]); + } + + private function breakdownUrl(?string $appKey, string $dimension, string $from, string $to, int $limit): string + { + $this->assertSafeDimension($dimension); + $path = $this->appScopedPath($appKey, "breakdown/{$dimension}"); + + return $this->url($path, ['from' => $from, 'to' => $to, 'limit' => (int) $limit]); + } + + private function seriesUrl(?string $appKey, string $range): string + { + $range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $path = $this->appScopedPath($appKey, 'series'); + + return $this->url($path, ['range' => $range]); + } + + private function dashboardUrl(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): string + { + $range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $query = [ + 'from' => $from, + 'to' => $to, + 'range' => $range, + 'paths_limit' => (int) $pathLimit, + 'breakdown_limit' => (int) $breakdownLimit, + ]; + if ($appKey === null) { + // apps_limit only applies to the server-wide leaderboard. + $query['apps_limit'] = (int) $appsLimit; + + return $this->url('/traffic/dashboard', $query); + } + $this->assertSafeKey($appKey); + + return $this->url("/app/{$appKey}/traffic/dashboard", $query); + } + + private function appsUrl(): string + { + return $this->url('/traffic/apps'); + } + + private function attributionUrl(): string + { + return $this->url('/traffic/attribution'); + } + + /** + * Build a traffic path, optionally scoped to a single (validated) app key. + */ + private function appScopedPath(?string $appKey, string $suffix): string + { + if ($appKey === null) { + return "/traffic/{$suffix}"; + } + $this->assertSafeKey($appKey); + + return "/app/{$appKey}/traffic/{$suffix}"; + } + + /** + * Reject anything that isn't a bare CUID2/UUID or hostname before it is + * interpolated into a shell-quoted `docker exec ... curl` command + * (see remoteFetch()/buildFetchCommand()). No quotes, spaces, slashes, or + * shell metacharacters. + */ + private function assertSafeKey(string $value): void + { + if ($value === '' || ! preg_match('/\A[A-Za-z0-9._:-]+\z/', $value)) { + throw new \InvalidArgumentException('Invalid traffic analytics app key.'); + } + } + + private function assertSafeDimension(string $dimension): void + { + if (! in_array($dimension, self::ALLOWED_DIMENSIONS, true)) { + throw new \InvalidArgumentException('Invalid traffic analytics dimension.'); + } + } + + private function url(string $path, array $query = []): string + { + // Colons in ISO-8601 Zulu timestamps are safe in a query string; keep them + // unencoded to match Sentinel's expected `from`/`to` format. + $qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query)); + + return $this->base.$path.$qs; + } + + private function cacheKey(string $url): string + { + return 'traffic:'.$this->server->uuid.':'.md5($url); + } + + /** + * True when warm() may issue its batched exec: either raw() is the base (real transport), + * or a subclass has explicitly overridden batchRemoteFetch to intercept the batch. A fake + * that only overrides raw() returns false, so warm() stays off the wire. + */ + private function usesBatchableTransport(): bool + { + if ((new \ReflectionMethod($this, 'raw'))->getDeclaringClass()->getName() === self::class) { + return true; + } + + return (new \ReflectionMethod($this, 'batchRemoteFetch'))->getDeclaringClass()->getName() !== self::class; + } + + protected function raw(string $url): string + { + return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url))); + } + + /** + * Fetch several URLs in one `docker exec` and warm each one's response cache under the + * same key raw() reads, so the subsequent per-call methods become cache hits. Cache hits + * are skipped, individual error/invalid responses are left uncached (the per-call fetch + * surfaces them), and any transport failure is swallowed — warming is an optimization, + * never a correctness dependency. + * + * @param array $urls + */ + protected function warm(array $urls): void + { + // Batching only helps when raw() uses the real remote transport. A subclass that + // overrides raw() to serve canned bodies (a test fake) — but not batchRemoteFetch — + // would otherwise reach real SSH here; skip and let its raw() answer each call. + if (! $this->usesBatchableTransport()) { + return; + } + + $misses = array_values(array_filter($urls, fn ($url) => ! Cache::has($this->cacheKey($url)))); + if ($misses === []) { + return; + } + + try { + $output = $this->batchRemoteFetch($misses); + } catch (\Throwable) { + return; + } + + $bodies = explode(self::RECORD_SEPARATOR, $output); + foreach ($misses as $index => $url) { + $body = $bodies[$index] ?? ''; + try { + Cache::put($this->cacheKey($url), $this->guard($body), 60); + } catch (\Throwable) { + // Invalid/error body: leave uncached so raw() re-fetches and reports it. + } + } + } + + protected function remoteFetch(string $url): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + [$this->buildFetchCommand($token, $url)], + $this->server, + false + ); + } + + /** + * @param array $urls + */ + protected function batchRemoteFetch(array $urls): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + [$this->buildBatchCommand($token, $urls)], + $this->server, + false + ); + } + + /** + * Build the `docker exec ... curl` command run inside the Sentinel container. + * + * The URL is double-quoted inside the inner `sh -c` string so the literal `&` + * between the `from`/`to` (and `limit`) query params is not interpreted as a + * shell background operator — which would background curl after `from=...` and + * truncate every multi-param request. The app key and dimension are validated + * (assertSafeKey/assertSafeDimension) before reaching here, so the URL cannot + * contain shell metacharacters that break out of the quoting. + */ + protected function buildFetchCommand(string $token, string $url): string + { + return "docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" \"{$url}\"'"; + } + + /** + * Build one `docker exec` that curls every URL in order and separates the responses + * with a 0x1E record separator, so warm() can split them back apart. escapeshellarg + * safely wraps the whole script; each URL stays double-quoted so its `&` is literal. + * + * @param array $urls + */ + protected function buildBatchCommand(string $token, array $urls): string + { + $script = implode(' ; ', array_map( + fn ($url) => "curl -s -H \"Authorization: Bearer {$token}\" \"{$url}\" ; printf '\\036'", + $urls + )); + + return 'docker exec coolify-sentinel sh -c '.escapeshellarg($script); + } + + private function guard(string $response): string + { + $payload = json_decode($response, true); + + if (! is_array($payload)) { + throw new \RuntimeException('Traffic analytics returned an invalid response.'); + } + + if (array_key_exists('error', $payload)) { + $error = data_get($payload, 'error'); + throw new \RuntimeException(is_string($error) ? $error : 'Traffic analytics request failed.'); + } + + return $response; + } +} diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php index d1d984a191..76cb4a5e8d 100644 --- a/app/Services/ServerTransfer/ServerTransferClaimer.php +++ b/app/Services/ServerTransfer/ServerTransferClaimer.php @@ -54,7 +54,7 @@ class ServerTransferClaimer if ($rebindSentinel && $server->settings) { $server->settings->sentinel_custom_url = $instanceUrl; $server->settings->ensureValidSentinelToken(); - // Leave sentinel disabled until operator enables metrics; endpoint is ready. + $server->settings->is_sentinel_enabled = true; $server->settings->save(); $sentinelRebound = true; } diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index bd67f8e39a..4a07e813ea 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -384,7 +384,8 @@ class ServerTransferExporter 'port' => (int) $server->port, 'user' => (string) $server->user, 'proxy' => $server->proxy?->toArray() ?? [], - 'is_build_server' => (bool) $server->is_build_server, + 'server_role' => $server->settings->effectiveServerRole()->value, + 'is_build_server' => $server->isBuildServer(), 'cloud_provider_token_uuid' => $server->cloudProviderToken?->uuid, 'settings' => $settingsPayload, ]; diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index b341725f9e..371844bcf9 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -2,6 +2,7 @@ namespace App\Services\ServerTransfer; +use App\Enums\ServerRole; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\CloudProviderToken; @@ -451,8 +452,13 @@ class ServerTransferImporter $server->uuid = $uuid; $server->save(); - if ($server->settings && data_get($payload, 'is_build_server')) { - $server->settings->is_build_server = true; + if ($server->settings) { + $serverRole = data_get($payload, 'server_role'); + if (! in_array($serverRole, array_column(ServerRole::cases(), 'value'), true)) { + $serverRole = data_get($payload, 'is_build_server') ? ServerRole::BUILD->value : ServerRole::BOTH->value; + } + $server->settings->server_role = $serverRole; + $server->settings->is_build_server = $serverRole === ServerRole::BUILD->value; $server->settings->save(); } diff --git a/app/Services/TrafficAnalyticsAggregator.php b/app/Services/TrafficAnalyticsAggregator.php new file mode 100644 index 0000000000..6e057a3816 --- /dev/null +++ b/app/Services/TrafficAnalyticsAggregator.php @@ -0,0 +1,39 @@ + $overviews + * @return array{overview: TrafficOverviewData, latencyApproximate: bool, uniquesApproximate: bool} + */ + public static function sumOverviews(array $overviews): array + { + $multi = count($overviews) > 1; + $sum = fn (string $prop) => array_sum(array_map(fn ($o) => $o->{$prop}, $overviews)); + $max = fn (string $prop) => empty($overviews) ? 0.0 : max(array_map(fn ($o) => $o->{$prop}, $overviews)); + + $overview = new TrafficOverviewData( + requests: $sum('requests'), + bytesIn: $sum('bytesIn'), + bytesOut: $sum('bytesOut'), + s2xx: $sum('s2xx'), + s3xx: $sum('s3xx'), + s4xx: $sum('s4xx'), + s5xx: $sum('s5xx'), + latencyP50: (float) $max('latencyP50'), + latencyP95: (float) $max('latencyP95'), + latencyP99: (float) $max('latencyP99'), + uniqueVisitors: $sum('uniqueVisitors'), + ); + + return [ + 'overview' => $overview, + 'latencyApproximate' => $multi, + 'uniquesApproximate' => $multi, + ]; + } +} diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 84e629fe1a..2c1de948ba 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,11 +90,8 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { - $requireStatementBoundary = true; - if (str_starts_with($sql, 'PGDMP')) { - $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; - $requireStatementBoundary = false; + return false; } $withoutComments = self::stripSqlComments($sql); @@ -103,9 +100,7 @@ class DatabaseBackupFileValidator return true; } - $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; - - return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php new file mode 100644 index 0000000000..311a4634a5 --- /dev/null +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -0,0 +1,111 @@ +databaseType($resource)) { + 'postgresql' => $dumpAll + ? 'psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} && (gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}' + : 'pg_restore --exit-on-error'.($replaceExisting ? ' --clean --if-exists' : '').' -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} '.$path, + 'mysql' => $dumpAll + ? $this->mysqlDumpAll('mysql', 'MYSQL', $path) + : '(gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE', + 'mariadb' => $dumpAll + ? $this->mysqlDumpAll('mariadb', 'MARIADB', $path) + : '(gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE', + 'mongodb' => 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='.$path, + default => throw new InvalidArgumentException('Database import is not supported for this database type.'), + }; + } + + public function buildPostgresRestoreScanScript(object $resource, string $path): ?string + { + if ($this->databaseType($resource) !== 'postgresql') { + return null; + } + + $escapedPath = escapeshellarg($path); + + // Token separator PostgreSQL treats as whitespace: real whitespace or a + // /* ... */ block comment (used to split keywords like FROM/**/PROGRAM). + $sep = '([[:space:]]|/\\*[^*]*\\*/)'; + + $sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program"; + $psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)"; + $escapedSqlPattern = escapeshellarg($sqlPattern); + $escapedPsqlPattern = escapeshellarg($psqlPattern); + $contents = "{ gunzip -cf {$escapedPath} 2>/dev/null || cat {$escapedPath}; }"; + $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}"; + $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); + $sqlScan = $scan($contents); + $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1'; + $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; + + return << "\$inspect"; then + {$blockedInspect} + fi + if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then + {$blockedInspect} + fi + if {$customScan}; then + {$blockedProgram} + fi +elif {$sqlScan}; then + {$blockedProgram} +fi +SH; + } + + public function buildPostgresSafetyCommand(object $resource, string $container, string $path): ?string + { + $script = $this->buildPostgresRestoreScanScript($resource, $path); + + if ($script === null) { + return null; + } + + return 'docker exec '.$container.' sh -c '.escapeshellarg($script); + } + + public function supports(object $resource): bool + { + return in_array($this->databaseType($resource), ['postgresql', 'mysql', 'mariadb', 'mongodb'], true); + } + + public function databaseType(object $resource): string + { + $class = $resource->getMorphClass(); + $type = ($resource instanceof ServiceDatabase || str_contains(strtolower($class), 'service')) + ? strtolower($resource->databaseType()) + : strtolower($class); + + return match (true) { + str_contains($type, 'postgres') => 'postgresql', + str_contains($type, 'mariadb') => 'mariadb', + str_contains($type, 'mysql') => 'mysql', + str_contains($type, 'mongo') => 'mongodb', + default => 'unsupported', + }; + } + + private function mysqlDumpAll(string $binary, string $prefix, string $path): string + { + $rootPassword = '${'.$prefix.'_ROOT_PASSWORD}'; + $database = '${'.$prefix.'_DATABASE:-default}'; + + return "for pid in \$({$binary} -u root -p{$rootPassword} -N -e \"SELECT id FROM information_schema.processlist WHERE user != 'root';\"); do {$binary} -u root -p{$rootPassword} -e \"KILL \$pid\" 2>/dev/null || true; done && {$binary} -u root -p{$rootPassword} -N -e \"SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');\" | {$binary} -u root -p{$rootPassword} && {$binary} -u root -p{$rootPassword} -e \"CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;\" && (gunzip -cf {$path} 2>/dev/null || cat {$path}) | {$binary} -u root -p{$rootPassword} {$database}"; + } +} diff --git a/app/Support/DatabaseImport/DatabaseImportException.php b/app/Support/DatabaseImport/DatabaseImportException.php new file mode 100644 index 0000000000..aeef2067ba --- /dev/null +++ b/app/Support/DatabaseImport/DatabaseImportException.php @@ -0,0 +1,13 @@ +|null $overrides + * @return array + */ + public static function sorted(?array $overrides): array + { + return collect($overrides ?? [])->sortKeys()->all(); + } + + public static function withoutPort(string $url): string + { + $parts = DomainUrlParts::split($url); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], path: $parts['path']); + } + + /** + * @param array|null $existing + * @return array{fqdn: ?string, overrides: ?array} + */ + public static function normalize(?string $fqdn, ?array $existing): array + { + if (blank($fqdn)) { + return ['fqdn' => null, 'overrides' => null]; + } + + $existingOverrides = $existing ?? []; + $normalizedDomains = collect(explode(',', $fqdn)) + ->map(fn (string $domain): string => trim($domain)) + ->filter() + ->filter(fn (string $domain): bool => isValidDomainUrl($domain)) + ->map(function (string $domain) use ($existingOverrides): array { + $portlessDomain = self::withoutPort($domain); + $parts = DomainUrlParts::split($domain); + $port = $parts['port'] !== '' + ? (int) $parts['port'] + : ($existingOverrides[$portlessDomain] ?? null); + + return ['domain' => $portlessDomain, 'port' => $port]; + }) + ->keyBy('domain') + ->values(); + + $effectiveOverrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]); + + $normalizedDomains = $normalizedDomains->map(function (array $domain) use ($effectiveOverrides): array { + if (filled($domain['port'])) { + return $domain; + } + + $counterpart = self::wwwCounterpart($domain['domain']); + $domain['port'] = $counterpart === null ? null : $effectiveOverrides->get($counterpart); + + return $domain; + }); + + $normalizedFqdn = $normalizedDomains->pluck('domain')->implode(','); + $overrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]) + ->all(); + + return [ + 'fqdn' => $normalizedFqdn === '' ? null : $normalizedFqdn, + 'overrides' => $overrides ?: null, + ]; + } + + private static function wwwCounterpart(string $url): ?string + { + $parts = DomainUrlParts::split($url); + $host = $parts['host']; + + if ($host === '') { + return null; + } + + $counterpartHost = str_starts_with(strtolower($host), 'www.') + ? substr($host, 4) + : 'www.'.$host; + + return DomainUrlParts::compose($parts['scheme'], $counterpartHost, path: $parts['path']); + } +} diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php index c86d5fa9a9..91dd609a92 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -46,6 +46,15 @@ class DomainUrlParts ]; } + public static function hasDnsRelevantChange(string $oldUrl, string $newUrl): bool + { + $old = self::split($oldUrl); + $new = self::split($newUrl); + + return $old['scheme'] !== $new['scheme'] + || strtolower($old['host']) !== strtolower($new['host']); + } + /** * @return array{scheme: string, host: string, port: string, path: string} */ diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php index cdeb75e58d..5d3ded154a 100644 --- a/app/Support/ServiceComposeUrl.php +++ b/app/Support/ServiceComposeUrl.php @@ -25,15 +25,7 @@ class ServiceComposeUrl ->map(fn ($url) => trim((string) $url)) ->filter(); - foreach ($urls as $url) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $errors[] = "Invalid URL: {$url}"; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'], true)) { - $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - } + $errors = ValidationPatterns::validateApplicationDomains($urls->implode(',')); $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 41b27f9ff9..442f892b26 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -96,11 +96,11 @@ class ValidationPatterns /** * Pattern for S3 bucket names. * - * Bucket names must be 3-63 lowercase characters, start and end with a - * letter or digit, and contain only lowercase letters, digits, dots, and - * hyphens. Additional semantic checks live in isValidS3BucketName(). + * Bucket names must be 3-63 characters, start and end with a letter or + * digit, and contain only letters, digits, dots, and hyphens. Uppercase + * letters remain supported for legacy and S3-compatible buckets. */ - public const S3_BUCKET_NAME_PATTERN = '/\A(?=.{3,63}\z)[a-z0-9][a-z0-9.-]*[a-z0-9]\z/'; + public const S3_BUCKET_NAME_PATTERN = '/\A(?=.{3,63}\z)[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]\z/'; /** * Pattern for Docker-compatible environment variable keys. @@ -108,6 +108,11 @@ class ValidationPatterns */ public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; + /** + * Pattern for environment variable keys written to shell-sourced files. + */ + public const SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_]*\z/u'; + /** * Characters that are valid in some URL positions but unsafe for values * that are later reused in shell assignment contexts. @@ -192,6 +197,43 @@ class ValidationPatterns return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1; } + /** + * Make an environment variable key safe to show in deployment logs. + * + * Control characters are escaped and long values are truncated so an + * unexpected key cannot corrupt or overflow the deployment log output. + */ + public static function displayShellEnvironmentVariableKey(string $value, int $maxLength = 80): string + { + $printable = str($value) + ->replace(["\0", "\r", "\n", "\t"], ['\\0', '\\r', '\\n', '\\t']) + ->value(); + + $printable = preg_replace_callback( + '/[\x00-\x1F\x7F]/', + fn (array $matches): string => sprintf('\\x%02X', ord($matches[0])), + $printable, + ); + + if ($printable === '') { + return '(empty)'; + } + + return str($printable)->limit($maxLength)->value(); + } + + /** + * Validate an environment variable key before writing it to a shell-sourced file. + */ + public static function validatedShellEnvironmentVariableKey(string $value): string + { + if (preg_match(self::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) !== 1) { + throw new \InvalidArgumentException('Invalid environment variable name '.self::displayShellEnvironmentVariableKey($value).'. Names must start with a letter or underscore and contain only letters, numbers, and underscores.'); + } + + return $value; + } + /** * Check if a string is a valid S3 bucket name. */ @@ -570,8 +612,23 @@ class ValidationPatterns continue; } - if (blank(parse_url($url, PHP_URL_HOST))) { + $host = parse_url($url, PHP_URL_HOST); + if (blank($host)) { $errors[] = "Invalid URL: {$url}"; + + continue; + } + + $port = parse_url($url, PHP_URL_PORT); + if ($port !== null && ($port < 1 || $port > 65535)) { + $errors[] = "Invalid port for URL: {$url}. The port must be between 1 and 65535."; + + continue; + } + + $unwrappedHost = trim((string) $host, '[]'); + if (! str_contains($unwrappedHost, '.') && filter_var($unwrappedHost, FILTER_VALIDATE_IP) === false) { + $errors[] = "Invalid URL: {$url}. The hostname must be a fully qualified domain name."; } } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index b8ff5df14b..87bd7f8748 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -9,6 +9,7 @@ use App\Models\Server; use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Stringable; trait ExecuteRemoteCommand { @@ -169,17 +170,16 @@ trait ExecuteRemoteCommand $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) { - $output = str($output)->trim(); - if ($output->startsWith('╔')) { - $output = "\n".$output; - } - // Sanitize output to ensure valid UTF-8 encoding before JSON encoding $sanitized_output = sanitize_utf8_text($output); + $log_output = str($sanitized_output)->trim(); + if ($log_output->startsWith('╔')) { + $log_output = "\n".$log_output; + } $new_log_entry = [ 'command' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command), - 'output' => $this->redact_sensitive_info($sanitized_output), + 'output' => $this->redact_sensitive_info($log_output), 'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'), 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, @@ -213,17 +213,7 @@ trait ExecuteRemoteCommand $this->application_deployment_queue->save(); - if ($this->save) { - if (data_get($this->saved_outputs, $this->save, null) === null) { - $this->saved_outputs->put($this->save, str()); - } - if ($append) { - $current_value = $this->saved_outputs->get($this->save); - $this->saved_outputs->put($this->save, str($current_value.str($sanitized_output)->trim())); - } else { - $this->saved_outputs->put($this->save, str($sanitized_output)->trim()); - } - } + $this->saveCommandOutput($sanitized_output, $append); }); $this->application_deployment_queue->update([ 'current_process_id' => $process->id(), @@ -252,6 +242,27 @@ trait ExecuteRemoteCommand } } + private function saveCommandOutput(string $output, bool $append): void + { + if (! $this->save) { + return; + } + + if ($append) { + $currentValue = $this->saved_outputs->get($this->save, ''); + $this->saved_outputs->put($this->save, str($currentValue.$output)); + + return; + } + + $this->saved_outputs->put($this->save, str($output)->trim()); + } + + private function trimmedSavedOutput(string $key): Stringable + { + return str($this->saved_outputs->get($key))->trim(); + } + /** * Add a log entry for SSH retry attempts */ diff --git a/app/Traits/HasNoindexDomains.php b/app/Traits/HasNoindexDomains.php index c3ba8a7d86..f3858cc61d 100644 --- a/app/Traits/HasNoindexDomains.php +++ b/app/Traits/HasNoindexDomains.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Support\Collection; @@ -19,7 +20,7 @@ trait HasNoindexDomains { return collect($this->noindex_domains ?? []) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->unique() ->values(); } @@ -27,7 +28,7 @@ trait HasNoindexDomains public function isDomainNoindexed(string $domain): bool { return $this->noindexDomains()->contains( - ValidationPatterns::normalizeApplicationDomainUrl($domain) + $this->normalizeNoindexDomain($domain) ); } @@ -35,7 +36,7 @@ trait HasNoindexDomains { $this->noindex_domains = collect($domains) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->intersect($this->currentDomains()) ->unique() ->values() @@ -57,7 +58,23 @@ trait HasNoindexDomains private function currentDomains(): Collection { - return collect(ValidationPatterns::applicationDomainList($this->fqdn)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)); + $domains = collect(ValidationPatterns::applicationDomainList($this->fqdn)); + $composeDomains = json_decode((string) ($this->getAttributes()['docker_compose_domains'] ?? null), true); + + if (is_array($composeDomains)) { + foreach ($composeDomains as $entry) { + $domains->push(...ValidationPatterns::applicationDomainList(composeDomainEntryString($entry))); + } + } + + return $domains + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)); + } + + private function normalizeNoindexDomain(string $domain): string + { + return DomainPortOverrides::withoutPort( + ValidationPatterns::normalizeApplicationDomainUrl($domain) + ); } } diff --git a/app/Traits/HasRestartLimit.php b/app/Traits/HasRestartLimit.php new file mode 100644 index 0000000000..edb461cdcd --- /dev/null +++ b/app/Traits/HasRestartLimit.php @@ -0,0 +1,73 @@ +mergeFillable(['restart_count', 'max_restart_count', 'restart_limit_reached', 'last_restart_at', 'last_restart_type']); + $this->mergeCasts([ + 'restart_count' => 'integer', + 'max_restart_count' => 'integer', + 'restart_limit_reached' => 'boolean', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', + ]); + } + + public function stoppedAfterRestartLimit(): bool + { + return str($this->status)->startsWith('exited') && $this->restart_limit_reached === true; + } + + public function trackRestartCount(int $observedRestartCount): bool + { + $state = (new RestartCountTracker)->evaluate( + previousRestartCount: $this->restart_count ?? 0, + observedRestartCount: $observedRestartCount, + maxRestartCount: $this->restartLimitMaximum(), + ); + + if ($state['restart_count_changed']) { + $hasCrashRestarts = $state['restart_count'] > 0; + $this->update([ + 'restart_count' => $state['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, + ]); + } + + if (! $state['restart_limit_reached']) { + return false; + } + + $claimed = $this->newQuery() + ->whereKey($this->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($claimed) { + $this->restart_limit_reached = true; + } + + return $claimed; + } + + public function resetRestartLimit(): void + { + $this->update([ + 'restart_count' => 0, + 'restart_limit_reached' => false, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + } + + public function restartLimitMaximum(): int + { + return $this->max_restart_count ?? 0; + } +} diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index 3e1600127e..f16fc3d953 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -23,6 +23,7 @@ class Button extends Component public mixed $canResource = null, public bool $autoDisable = true, public bool $isHighlighted = false, + public bool $isError = false, public ?string $tooltip = null, ) { // Handle authorization-based disabling @@ -37,6 +38,8 @@ class Button extends Component if ($this->noStyle) { $this->defaultClass = ''; + } elseif ($this->isError) { + $this->defaultClass .= ' button-error'; } } diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 7831c9f024..e94b181375 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -25,6 +25,7 @@ class Input extends Component public bool $readonly = false, public ?string $helper = null, public bool $allowToPeak = true, + public bool $copyable = false, public bool $isMultiline = false, public string $defaultClass = 'input', public string $autocomplete = 'off', @@ -34,6 +35,8 @@ class Input extends Component public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public bool $loading = false, + public string $loadingText = 'Loading...', ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { @@ -70,9 +73,15 @@ class Input extends Component } // Durable class (not type-attr based): Alpine may toggle type to "text" when revealing, // and settings-workspace CSS otherwise overrides utility padding-right. - if ($this->type === 'password' && $this->allowToPeak) { + $hasPeek = $this->type === 'password' && $this->allowToPeak; + if ($hasPeek) { $this->defaultClass = $this->defaultClass.' input-with-password-toggle'; } + if ($this->copyable) { + // Reserve clearance for a single copy button, or for both the peek eye + // and the copy button when the field is a maskable password. + $this->defaultClass = $this->defaultClass.($hasPeek ? ' input-with-copy-and-peek' : ' input-with-copy-button'); + } // $this->label = Str::title($this->label); return view('components.forms.input'); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index b8001497ba..f616dd65fe 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -4,6 +4,7 @@ use App\Actions\Shared\MigrateResourceToDestination; use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Models\ApplicationSetting; use App\Models\Environment; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; @@ -140,7 +141,8 @@ function sharedDataApplications() 'gpu_device_ids' => 'string|nullable', 'gpu_options' => 'string|nullable', 'is_consistent_container_name_enabled' => 'boolean', - 'custom_internal_name' => 'string|nullable', + 'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()], + 'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH, 'preview_url_template' => 'string', 'max_restart_count' => 'integer|min:0', 'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS, @@ -408,6 +410,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('gpu_options'); $request->offsetUnset('is_consistent_container_name_enabled'); $request->offsetUnset('custom_internal_name'); + $request->offsetUnset('custom_container_name_prefix'); $request->offsetUnset('docker_compose_raw'); $request->offsetUnset('tags'); } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 2fb0bb3f53..7743304c34 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -14,6 +14,7 @@ use Spatie\Url\Url; function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null) { $commit = $commit ?: ($application->git_commit_sha ?: 'HEAD'); + $commit = validateGitRef($commit, 'deployment commit'); $application_id = $application->id; $deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}"); $deployment_url = $deployment_link->getPath(); diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 1a1ad0a994..bb93547c4d 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,6 +1,7 @@ bound('request') ? request() : null; + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $payload = AuditEvent::redactContext(array_merge([ + 'event' => $event, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'user_id' => $user?->id, + 'user_email' => $user?->email, + 'team_id' => $token ? data_get($token, 'team_id') : null, + 'token_id' => $token?->id, + 'token_name' => $token?->name, + 'method' => $request?->method(), + 'path' => $request?->path(), + ], $context)); + + Log::channel('audit')->{$level}($event, $payload); } catch (Throwable) { + // The database sink remains available when the optional channel fails. } + + AuditEvent::record($event, $context, $level); } } diff --git a/bootstrap/helpers/auth.php b/bootstrap/helpers/auth.php new file mode 100644 index 0000000000..7580fc1e06 --- /dev/null +++ b/bootstrap/helpers/auth.php @@ -0,0 +1,14 @@ +header('CF-Connecting-IP'); + + if (isCloud() && is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false) { + return $cloudflareIp; + } + + return (string) $request->ip(); +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 00300d26a2..6704902f88 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -349,17 +349,32 @@ function generateApplicationContainerName(Application $application, $pull_reques // TODO: refactor generateApplicationContainerName, we do not need $application and $pull_request_id $consistent_container_name = $application->settings->is_consistent_container_name_enabled; - $now = now()->format('Hisu'); + $name = $consistent_container_name ? ($application->settings->custom_internal_name ?: $application->uuid) : $application->uuid; + $now = now()->format('Ymd\THis'); if ($pull_request_id !== 0 && $pull_request_id !== null) { - return $application->uuid.'-pr-'.$pull_request_id; + return $name.'-pr-'.$pull_request_id; } else { if ($consistent_container_name) { - return $application->uuid; + return $name; } - return $application->uuid.'-'.$now; + return ($application->settings->custom_container_name_prefix ?: $application->uuid).'-'.$now; } } + +/** + * Generated (rolling update) container names end with the timestamp from generateApplicationContainerName(). + * Drop the legacy pattern once containers created before the ISO 8601 suffix are gone. + */ +function isGeneratedContainerName(string $containerName): bool +{ + $isoTimestampSuffix = '/-\d{8}T\d{6}$/'; + $legacyTimestampSuffix = '/-\d{12}$/'; + + return preg_match($isoTimestampSuffix, $containerName) === 1 + || preg_match($legacyTimestampSuffix, $containerName) === 1; +} + function get_port_from_dockerfile($dockerfile): ?int { $dockerfile_array = explode("\n", $dockerfile); @@ -530,7 +545,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = []) { $labels = collect([]); if ($serviceLabels) { @@ -554,7 +569,8 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($schema === 'https' && ! $is_force_https_enabled) { $siteAddress = "http://{$host}, https://{$host}"; } - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); $handle = 'handle_path'; if (! $is_stripprefix_enabled) { $handle = 'handle'; @@ -595,12 +611,75 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($is_http_basic_auth_enabled) { $labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\""); } + if ($is_traffic_analytics_enabled) { + $labels->push("caddy_{$loop}.log.output=file /traffic/access.log"); + // Explicit lumberjack roll options so the access log doesn't grow unbounded + // (Caddy's defaults are undocumented). caddy-docker-proxy renders these dotted + // keys as a nested block: output file /traffic/access.log { roll_size 20MiB; roll_keep 5; roll_keep_for 168h }. + // Rotation is rename-based, which is safe for Sentinel's tailer (it reopens on inode change). + $labels->push("caddy_{$loop}.log.output.roll_size=20MiB"); + $labels->push("caddy_{$loop}.log.output.roll_keep=5"); + $labels->push("caddy_{$loop}.log.output.roll_keep_for=168h"); + $labels->push("caddy_{$loop}.log.format=json"); + $labels->push("caddy_{$loop}.log_append=coolify_app_id {$uuid}"); + } } return $labels->sort(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true) +function firstDockerComposeServicePort(mixed $service): ?int +{ + $portDefinitions = collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])); + + foreach ($portDefinitions as $definition) { + $protocol = is_array($definition) + ? data_get($definition, 'protocol', 'tcp') + : (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp'); + if ($protocol !== 'tcp') { + continue; + } + + $port = is_array($definition) + ? data_get($definition, 'target') + : str((string) $definition)->before('/')->afterLast(':')->value(); + + if (is_numeric($port) && (int) $port >= 1 && (int) $port <= 65535) { + return (int) $port; + } + } + + return null; +} + +function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int +{ + return dockerComposeServicePorts($compose, $serviceName)[0] ?? null; +} + +function dockerComposeServicePorts(?string $compose, ?string $serviceName): array +{ + if (blank($compose) || blank($serviceName)) { + return []; + } + + try { + $services = data_get(Yaml::parse($compose), 'services', []); + } catch (Throwable) { + return []; + } + + $service = is_array($services) ? ($services[$serviceName] ?? []) : []; + + return collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])) + ->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]])) + ->filter(fn ($port) => $port !== null) + ->unique()->values()->all(); +} + +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -655,7 +734,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $host = $url->getHost(); $path = $url->getPath(); $schema = $url->getScheme(); - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); if (is_null($port) && ! is_null($onlyPort)) { $port = $onlyPort; } @@ -898,6 +978,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -914,6 +995,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; } @@ -931,6 +1014,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $application->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -945,6 +1029,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $application->domain_port_overrides ?? [], )); } } @@ -972,6 +1058,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -987,6 +1074,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; } @@ -1003,6 +1092,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1016,6 +1106,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $preview->domain_port_overrides ?? [], )); } } @@ -1479,10 +1571,17 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int +function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int|string { + if ($lines === 'all') { + return 'all'; + } + $lines = filter_var($lines, FILTER_VALIDATE_INT); - if ($lines === false || $lines <= 0) { + if ($lines === -1) { + return 'all'; + } + if ($lines === false || $lines < -1) { return $default; } @@ -1494,7 +1593,7 @@ function parseLogTimestampFlag(mixed $showTimestamps): bool return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; } -function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +function buildContainerLogsCommand(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $command = "docker logs -n {$lines}"; if ($server->isSwarm()) { @@ -1508,7 +1607,7 @@ function buildContainerLogsCommand(Server $server, string $container_id, int $li return "{$command} ".escapeshellarg($container_id).' 2>&1'; } -function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +function getContainerLogs(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server); $output = removeAnsiColors($output); @@ -1614,6 +1713,10 @@ function generateDockerBuildArgs($variables): Collection return $variables->map(function ($var) { $key = is_array($var) ? data_get($var, 'key') : $var->key; + if (! ValidationPatterns::isValidEnvironmentVariableKey((string) $key)) { + throw new InvalidArgumentException('Invalid environment variable key.'); + } + // Only return the key - Docker will get the value from the environment return '--build-arg '.escapeshellarg((string) $key); }); @@ -1633,19 +1736,17 @@ function generateDockerEnvFlags($variables): string ->map(function ($var) { $key = is_array($var) ? data_get($var, 'key') : $var->key; $value = is_array($var) ? data_get($var, 'value') : $var->value; - $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); - if ($isMultiline) { - // For multiline variables, strip surrounding quotes and escape for bash - $raw_value = trim($value, "'"); - $escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value); - - return "-e {$key}=\"{$escaped_value}\""; + if (! ValidationPatterns::isValidEnvironmentVariableKey((string) $key)) { + throw new InvalidArgumentException('Invalid environment variable key.'); } - $escaped_value = escapeshellarg($value); + $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); + if ($isMultiline) { + $value = trim($value, "'"); + } - return "-e {$key}={$escaped_value}"; + return '-e '.escapeshellarg("{$key}={$value}"); }) ->implode(' '); } diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 4e4ad73e6f..8be6d9a77a 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -68,6 +68,52 @@ function isValidDomainUrl(string $url): bool return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false; } +function domainConflictKey(string $domain): string +{ + $domain = str($domain)->endsWith('/') + ? str($domain)->beforeLast('/')->toString() + : $domain; + + return preg_replace('#^https?://#i', '', $domain) ?? $domain; +} + +/** + * @return Collection + */ +function applicationDomainEntries(Application $application): Collection +{ + $entries = collect(explode(',', (string) $application->fqdn)) + ->filter(fn ($domain) => $domain !== '') + ->map(fn ($domain) => [ + 'domain' => str($domain)->finish('/')->beforeLast('/')->toString(), + 'service_name' => null, + ]); + + if ($application->build_pack !== 'dockercompose' || empty($application->docker_compose_domains)) { + return $entries->values(); + } + + $composeDomains = json_decode($application->docker_compose_domains, true); + if (! is_array($composeDomains)) { + return $entries->values(); + } + + foreach ($composeDomains as $serviceName => $domainConfig) { + foreach (explode(',', (string) data_get($domainConfig, 'domain')) as $domain) { + if ($domain === '') { + continue; + } + + $entries->push([ + 'domain' => str($domain)->finish('/')->beforeLast('/')->toString(), + 'service_name' => (string) $serviceName, + ]); + } + } + + return $entries->values(); +} + function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) { $conflicts = []; @@ -80,8 +126,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, if ($resource) { if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') { - $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain'); - $domains = collect($domains); + $domains = applicationDomainEntries($resource)->pluck('domain'); } else { $domains = collect($resource->fqdns); } @@ -96,7 +141,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, $domain = str($domain)->beforeLast('/'); } - return str($domain); + return domainConflictKey((string) $domain); }); // Filter applications by team if we have a current team @@ -108,13 +153,9 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, } $apps = $appsQuery->get(); foreach ($apps as $app) { - $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); - foreach ($list_of_domains as $domain) { - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { + foreach (applicationDomainEntries($app) as $domainEntry) { + $naked_domain = $domainEntry['domain']; + if ($domains->contains(domainConflictKey($naked_domain))) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { $conflicts[] = [ @@ -122,16 +163,18 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, 'resource_name' => $app->name, 'resource_link' => $app->link(), 'resource_type' => 'application', - 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", + 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), + ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), ]; } - } elseif ($domain) { + } else { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->name, 'resource_link' => $app->link(), 'resource_type' => 'application', - 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", + 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), + ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), ]; } } @@ -153,7 +196,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { + if ($domains->contains(domainConflictKey($naked_domain))) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { $conflicts[] = [ @@ -185,7 +228,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { + if ($domains->contains(domainConflictKey($naked_domain))) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => 'Coolify Instance', @@ -219,7 +262,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te $domain = str($domain)->beforeLast('/'); } - return str($domain); + return domainConflictKey((string) $domain); }); $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']); @@ -231,51 +274,17 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te } foreach ($applications as $app) { - if (! is_null($app->fqdn)) { - $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); - foreach ($list_of_domains as $domain) { - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->name, - 'resource_uuid' => $app->uuid, - 'resource_type' => 'application', - 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", - ]; - } - } - } - - if ($app->build_pack === 'dockercompose' && ! empty($app->docker_compose_domains)) { - $dockerComposeDomains = json_decode($app->docker_compose_domains, true); - if (is_array($dockerComposeDomains)) { - foreach ($dockerComposeDomains as $serviceName => $domainConfig) { - $domainValue = data_get($domainConfig, 'domain'); - if (empty($domainValue)) { - continue; - } - $list_of_domains = collect(explode(',', $domainValue))->filter(fn ($fqdn) => $fqdn !== ''); - foreach ($list_of_domains as $domain) { - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->name, - 'resource_uuid' => $app->uuid, - 'resource_type' => 'application', - 'service_name' => $serviceName, - 'message' => "Domain $naked_domain is already in use by application '{$app->name}' (service: {$serviceName})", - ]; - } - } - } + foreach (applicationDomainEntries($app) as $domainEntry) { + $naked_domain = $domainEntry['domain']; + if ($domains->contains(domainConflictKey($naked_domain))) { + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->name, + 'resource_uuid' => $app->uuid, + 'resource_type' => 'application', + 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), + ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), + ]; } } } @@ -290,7 +299,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { + if ($domains->contains(domainConflictKey($naked_domain))) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->service->name ?? 'Unknown Service', @@ -310,7 +319,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { + if ($domains->contains(domainConflictKey($naked_domain))) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => 'Coolify Instance', @@ -443,6 +452,26 @@ function getComposeServiceDomainString(array|Collection $domains, string $servic return $matches[0]['domain']; } +/** + * Determine whether a compose service already has a domain-map entry, including + * an explicitly empty entry left when a user removes its generated domain. + * + * @param array|Collection $domains + */ +function hasComposeServiceDomainEntry(array|Collection $domains, string $serviceName): bool +{ + $normalized = normalizeComposeServiceName($serviceName); + + foreach (collect($domains)->keys() as $key) { + $key = (string) $key; + if ($key === $serviceName || normalizeComposeServiceName($key) === $normalized) { + return true; + } + } + + return false; +} + function composeDomainEntryString(mixed $entry): ?string { if (is_object($entry)) { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index b47e570477..91411c61f7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -9,6 +9,7 @@ use App\Models\LocalPersistentVolume; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Support\ValidationPatterns; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; @@ -97,6 +98,42 @@ function validateDockerComposeForInjection(string $composeYaml): void } } } + + if (is_array($serviceConfig) && isset($serviceConfig['networks']) && is_array($serviceConfig['networks'])) { + foreach ($serviceConfig['networks'] as $networkKey => $networkDetails) { + if (is_int($networkKey) && (is_string($networkDetails) || is_int($networkDetails))) { + validateComposeNetworkName((string) $networkDetails, 'service network'); + } elseif (is_string($networkKey) || is_int($networkKey)) { + validateComposeNetworkName((string) $networkKey, 'service network'); + } + } + } + } + + if (isset($parsed['networks']) && is_array($parsed['networks'])) { + foreach ($parsed['networks'] as $networkName => $networkConfig) { + if (is_string($networkName) || is_int($networkName)) { + validateComposeNetworkName((string) $networkName); + } + if (is_array($networkConfig) && isset($networkConfig['name']) && is_string($networkConfig['name'])) { + validateComposeNetworkName($networkConfig['name'], 'network name field'); + } + } + } +} + +/** + * Reject Docker Compose network names that are not valid Docker identifiers. + * + * @throws Exception If the network name is not a valid Docker network identifier + */ +function validateComposeNetworkName(string $networkName, string $context = 'network name'): void +{ + if ($networkName === '' || ! ValidationPatterns::isValidDockerNetwork($networkName)) { + throw new Exception( + 'Invalid Docker Compose '.$context. + '. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.' + ); } } @@ -525,8 +562,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services)); if ($originalServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $originalServiceName); - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $originalServiceName)) { $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) { @@ -648,12 +684,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Only add domain if the service exists if ($composeServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $composeServiceName); - // Update domain using URL with port if applicable $domainValue = $port ? $urlWithPort : $url; - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $composeServiceName)) { $resource->docker_compose_domains = json_encode(putComposeServiceDomain( $domains, $composeServiceName, @@ -1260,29 +1294,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($docker_compose_domains->count() > 0) { $found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName); if ($found_fqdn) { - $fqdns = collect($found_fqdn); + $fqdns = str($found_fqdn)->explode(',')->map(fn ($fqdn) => trim($fqdn))->filter(); } else { $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $preview->domain_port_overrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->save(); } } } @@ -1359,6 +1385,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $previewForPorts = $isPullRequest + ? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first()) + : null; + $domainPortOverrides = $isPullRequest + ? ($previewForPorts?->domain_port_overrides ?? []) + : ($originalResource->domain_port_overrides ?? []); + $onlyPort = firstDockerComposeServicePort($service); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1374,8 +1407,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -1389,9 +1424,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -1405,8 +1442,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -1418,9 +1457,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -1557,7 +1598,6 @@ function serviceParser(Service $resource): Collection $envComments = extractYamlEnvironmentComments($compose); $server = data_get($resource, 'server'); - $allServices = get_service_templates(); try { $yaml = Yaml::parse($compose); @@ -1690,22 +1730,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -1849,11 +1874,7 @@ function serviceParser(Service $resource): Collection // Only save fqdn to ServiceApplication, not ServiceDatabase if ($isServiceApplication && is_null($savedService->fqdn)) { // Save URL (with scheme) to database, not FQDN - if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) { - $savedService->fqdn = $urlWithPort; - } else { - $savedService->fqdn = $urlWithPort; - } + $savedService->fqdn = $url; $savedService->save(); } @@ -2079,22 +2100,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -2577,7 +2583,7 @@ function serviceParser(Service $resource): Collection projectName: $resource->project()->name, resourceName: $resource->name, type: 'service', - subType: $isDatabase ? 'database' : 'application', + subType: $savedService instanceof ServiceDatabase ? 'database' : 'application', subId: $savedService->id, subName: $savedService->human_name ?? $savedService->name, environment: $resource->environment->name, @@ -2636,6 +2642,9 @@ function serviceParser(Service $resource): Collection $redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($originalResource, 'redirect') : 'both'; + $onlyPort = $originalResource instanceof ServiceApplication + ? $originalResource->getRequiredPort() + : $predefinedPort; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -2651,6 +2660,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2666,7 +2677,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort, + onlyPort: $onlyPort, + predefinedPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2682,6 +2695,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2695,7 +2710,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort, + onlyPort: $onlyPort, + predefinedPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index fe639950be..8f77ce232c 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -4,10 +4,104 @@ use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Server; +use App\Support\ValidationPatterns; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Symfony\Component\Yaml\Yaml; +function traefikAccessLogCommands(bool $enabled): array +{ + if (! $enabled) { + return []; + } + + return [ + '--accesslog=true', + '--accesslog.filepath=/traefik/access.log', + '--accesslog.format=json', + '--accesslog.fields.headers.names.Cf-Connecting-Ip=keep', + '--accesslog.fields.headers.names.Cf-Ipcountry=keep', + '--accesslog.fields.headers.names.Cf-Cache-Status=keep', + '--accesslog.fields.headers.names.Cf-Verified-Bot=keep', + '--accesslog.fields.headers.names.Cf-Ray=keep', + // Kept so Sentinel can resolve the real client IP behind a non-Cloudflare + // reverse proxy (leftmost X-Forwarded-For entry) and report User-Agents/referrers. + '--accesslog.fields.headers.names.X-Forwarded-For=keep', + '--accesslog.fields.headers.names.User-Agent=keep', + '--accesslog.fields.headers.names.Referer=keep', + ]; +} + +function applyTrafficAnalyticsToProxyConfiguration(Server $server, string $configuration): string +{ + $config = Yaml::parse($configuration); + + if (! is_array($config)) { + throw new RuntimeException('Proxy configuration must be a YAML mapping.'); + } + + $config = applyTrafficAnalyticsToProxyConfigArray($server, $config); + + return Yaml::dump($config, 12, 2); +} + +function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config): array +{ + $enabled = $server->isTrafficAnalyticsEnabled(); + + if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { + $managedCommands = traefikAccessLogCommands(true); + $commands = data_get($config, 'services.traefik.command', []); + + if (! is_array($commands)) { + throw new RuntimeException('Traefik commands must be a YAML list.'); + } + + $commands = array_values(array_filter( + $commands, + fn (mixed $command): bool => ! in_array($command, $managedCommands, true) + )); + + if ($enabled) { + $commands = [...$commands, ...$managedCommands]; + } + + data_set($config, 'services.traefik.command', $commands); + unset($config['services']['traefik-logrotate']); + + if ($enabled && ! $server->isSwarm() && ! isDev()) { + $proxyPath = $server->proxyPath(); + $config['services']['traefik-logrotate'] = [ + 'image' => 'alpine:3.20', + 'restart' => RESTART_MODE, + 'volumes' => [ + "{$proxyPath}:/traefik", + ], + 'labels' => [ + 'coolify.managed=true', + ], + 'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'', + ]; + } + } elseif ($server->proxyType() === ProxyTypes::CADDY->value) { + $trafficVolume = $server->proxyPath().':/traffic'; + $volumes = data_get($config, 'services.caddy.volumes', []); + + if (! is_array($volumes)) { + throw new RuntimeException('Caddy volumes must be a YAML list.'); + } + + $volumes = array_values(array_filter($volumes, fn (mixed $volume): bool => $volume !== $trafficVolume)); + if ($enabled) { + $volumes[] = $trafficVolume; + } + + data_set($config, 'services.caddy.volumes', $volumes); + } + + return $config; +} + /** * Check if a network name is a Docker predefined system network. * These networks cannot be created, modified, or managed by docker network commands. @@ -22,6 +116,28 @@ function isDockerPredefinedNetwork(string $network): bool return in_array($network, ['default', 'host'], true); } +function isUsableDockerNetworkName(mixed $network): bool +{ + return is_string($network) + && $network !== '' + && ! isDockerPredefinedNetwork($network) + && ValidationPatterns::isValidDockerNetwork($network); +} + +/** + * Create a Docker network when it does not exist. The network name is always a single escaped argument. + */ +function dockerNetworkEnsureCommand(string $network, bool $overlay = false, bool $quietCreate = false): string +{ + $safe = escapeshellarg($network); + $createFlags = $overlay + ? '--driver overlay --attachable' + : '--attachable'; + $quiet = $quietCreate ? ' >/dev/null' : ''; + + return "docker network inspect {$safe} >/dev/null 2>&1 || docker network create {$createFlags} {$safe}{$quiet}"; +} + function collectProxyDockerNetworksByServer(Server $server) { if (! $server->isFunctional()) { @@ -82,12 +198,8 @@ function collectDockerNetworksByServer(Server $server) $networks->push($network); $allNetworks->push($network); } - $networks = collect($networks)->flatten()->unique()->filter(function ($network) { - return ! isDockerPredefinedNetwork($network); - }); - $allNetworks = $allNetworks->flatten()->unique()->filter(function ($network) { - return ! isDockerPredefinedNetwork($network); - }); + $networks = collect($networks)->flatten()->unique()->filter(fn ($network) => isUsableDockerNetworkName($network)); + $allNetworks = $allNetworks->flatten()->unique()->filter(fn ($network) => isUsableDockerNetworkName($network)); if ($server->isSwarm()) { if ($networks->count() === 0) { $networks = collect(['coolify-overlay']); @@ -113,7 +225,7 @@ function connectProxyToNetworks(Server $server) $safe = escapeshellarg($network); return [ - "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null", + dockerNetworkEnsureCommand($network, overlay: true, quietCreate: true), "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", "echo 'Successfully connected coolify-proxy to {$safe} network.'", ]; @@ -145,25 +257,14 @@ function ensureProxyNetworksExist(Server $server) { ['allNetworks' => $networks] = collectDockerNetworksByServer($server); - if ($server->isSwarm()) { - $commands = $networks->map(function ($network) { - $safe = escapeshellarg($network); + $commands = $networks->map(function ($network) use ($server) { + $safe = escapeshellarg($network); - return [ - "echo 'Ensuring network {$safe} exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}", - ]; - }); - } else { - $commands = $networks->map(function ($network) { - $safe = escapeshellarg($network); - - return [ - "echo 'Ensuring network {$safe} exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}", - ]; - }); - } + return [ + "echo 'Ensuring network {$safe} exists...'", + dockerNetworkEnsureCommand($network, overlay: $server->isSwarm()), + ]; + }); return $commands->flatten(); } @@ -266,10 +367,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command }); if ($proxy_type === ProxyTypes::TRAEFIK->value) { $labels = [ - 'traefik.enable=true', - 'traefik.http.routers.traefik.entrypoints=http', - 'traefik.http.routers.traefik.service=api@internal', - 'traefik.http.services.traefik.loadbalancer.server.port=8080', + 'traefik.enable=false', 'coolify.managed=true', 'coolify.proxy=true', ]; @@ -279,7 +377,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command 'services' => [ 'traefik' => [ 'container_name' => 'coolify-proxy', - 'image' => 'traefik:v3.6', + 'image' => 'traefik:v3.7', 'restart' => RESTART_MODE, 'extra_hosts' => [ 'host.docker.internal:host-gateway', @@ -325,7 +423,6 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command if (isDev()) { $config['services']['traefik']['command'][] = '--api.insecure=true'; $config['services']['traefik']['command'][] = '--log.level=debug'; - $config['services']['traefik']['command'][] = '--accesslog.filepath=/traefik/access.log'; $config['services']['traefik']['command'][] = '--accesslog.bufferingsize=100'; $config['services']['traefik']['volumes'][] = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/:/traefik'; } else { @@ -358,6 +455,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command $config['services']['traefik']['command'][] = $custom_command; } } + } elseif ($proxy_type === 'CADDY') { $config = [ 'networks' => $array_of_networks->toArray(), @@ -396,6 +494,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command return null; } + $config = applyTrafficAnalyticsToProxyConfigArray($server, $config); $config = Yaml::dump($config, 12, 2); SaveProxyConfiguration::run($server, $config); diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 982dda5511..241368d388 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -346,7 +346,7 @@ function remove_iip($text) $text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text); // GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh) - $text = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{36,})\b/', REDACTED, $text); + $text = preg_replace('/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/', REDACTED, $text); // GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token) $text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 07fdeb086f..96257a6323 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -1,5 +1,30 @@ asset($defaultLogo), + 'logo_cdn_url' => asset($defaultLogo), + 'logo_default_url' => asset($defaultLogo), + ]; + } + + if (str_starts_with($logo, 'svg/')) { + $logo = 'svgs/'.str($logo)->after('svg/'); + } + + $logo = ltrim($logo, '/'); + + return [ + 'logo' => asset($logo), + 'logo_cdn_url' => 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo, + 'logo_default_url' => asset($defaultLogo), + ]; +} + use App\Models\Application; use App\Models\Service; use App\Models\ServiceApplication; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8d0ab9b8c5..dbe8814bd7 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -12,6 +12,8 @@ use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; +use App\Models\Project; +use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -568,8 +570,11 @@ function refreshSession(?Team $team = null): void $team = Team::find($currentTeam->id); } if (! $team) { - // Fall back to any team the user still belongs to. - $team = User::query()->find(Auth::id())?->teams()->first(); + // Fall back to the user's resolvable team (stored choice, or their + // sole team). Returns null for a multi-team user with no valid stored + // choice, so an arbitrary first team is never silently persisted — + // the user is sent to the selection screen instead. + $team = User::query()->find(Auth::id())?->resolveStoredTeam(); } } @@ -579,8 +584,13 @@ function refreshSession(?Team $team = null): void 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. + // dereferencing null, and drop the persisted choice so it is not + // restored on next login. session()->forget('currentTeam'); + $user = Auth::user(); + if ($user && ! is_null($user->current_team_id)) { + $user->forceFill(['current_team_id' => null])->saveQuietly(); + } return; } @@ -591,6 +601,15 @@ function refreshSession(?Team $team = null): void return $team; }); session(['currentTeam' => $team]); + + // Persist the active team so it can be restored after logout/login — but + // never while an admin is impersonating, so viewing another user's account + // does not overwrite that user's real last-active team. + $user = Auth::user(); + if ($user && ! session('impersonating') && $user->current_team_id !== $team->id) { + $user->current_team_id = $team->id; + $user->saveQuietly(); + } } function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null) { @@ -815,6 +834,54 @@ function firstDomainFromList(?string $fqdns): string { return trim((string) str($fqdns ?? '')->explode(',')->first()); } +function profile_avatar_url(User $user): string +{ + if ($user->avatar_storage_type === 's3') { + $url = s3_image_url($user->avatar_s3_storage_id, $user->avatar_path, $user->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('profile.avatar', ['v' => $user->updated_at->timestamp]); +} + +function project_icon_url(Project $project): string +{ + if ($project->icon_storage_type === 's3') { + $url = s3_image_url($project->icon_s3_storage_id, $project->icon_path, $project->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('project.icon', [ + 'project_uuid' => $project->uuid, + 'v' => $project->updated_at->timestamp, + ]); +} + +function s3_image_url(?int $storageId, ?string $path, int $version): ?string +{ + if (! $storageId || blank($path)) { + return null; + } + + $storage = S3Storage::query() + ->whereKey($storageId) + ->whereTeamId(0) + ->where('is_usable', true) + ->first(); + + if (! $storage) { + return null; + } + + $baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl(); + + return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; +} + /** * If fqdn is set, return it, otherwise return public ip. */ @@ -2333,7 +2400,7 @@ function get_public_ips() } } -function isAnyDeploymentInprogress() +function isAnyDeploymentInprogress(bool $showAll = false) { $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); @@ -2350,34 +2417,31 @@ function isAnyDeploymentInprogress() if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') { $horizonJobIds[] = $runningJob->horizon_job_id; - // Get application and team information - $application = Application::find($runningJob->application_id); - $teamMembers = []; - $deploymentUrl = ''; + if ($showAll) { + $application = Application::find($runningJob->application_id); + $teamMembers = []; + $deploymentUrl = ''; - if ($application) { - // Get team members through the application's project - $team = $application->team(); - if ($team) { - $teamMembers = $team->members()->pluck('email')->toArray(); + if ($application) { + $team = $application->team(); + if ($team) { + $teamMembers = $team->members()->pluck('email')->toArray(); + } + + if ($runningJob->deployment_url) { + $deploymentUrl = base_url().$runningJob->deployment_url; + } } - // Construct the full deployment URL - if ($runningJob->deployment_url) { - $baseUrl = base_url(); - $deploymentUrl = $baseUrl.$runningJob->deployment_url; - } + $deploymentDetails[] = [ + 'application_name' => $runningJob->application_name ?? 'Unknown', + 'server_name' => $runningJob->server_name ?? 'Unknown', + 'deployment_url' => $deploymentUrl, + 'team_members' => $teamMembers, + 'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'), + 'horizon_job_id' => $runningJob->horizon_job_id, + ]; } - - $deploymentDetails[] = [ - 'id' => $runningJob->id, - 'application_name' => $runningJob->application_name ?? 'Unknown', - 'server_name' => $runningJob->server_name ?? 'Unknown', - 'deployment_url' => $deploymentUrl, - 'team_members' => $teamMembers, - 'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'), - 'horizon_job_id' => $runningJob->horizon_job_id, - ]; } } @@ -2386,30 +2450,41 @@ function isAnyDeploymentInprogress() exit(0); } - // Display enhanced deployment information - echo "\n=== Running Deployments ===\n"; - echo 'Total active deployments: '.count($horizonJobIds)."\n\n"; - - foreach ($deploymentDetails as $index => $deployment) { - echo 'Deployment #'.($index + 1).":\n"; - echo ' Application: '.$deployment['application_name']."\n"; - echo ' Server: '.$deployment['server_name']."\n"; - echo ' Started: '.$deployment['created_at']."\n"; - if ($deployment['deployment_url']) { - echo ' URL: '.$deployment['deployment_url']."\n"; - } - if (! empty($deployment['team_members'])) { - echo ' Team members: '.implode(', ', $deployment['team_members'])."\n"; - } else { - echo " Team members: No team members found\n"; - } - echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n"; - echo "\n"; - } + echo formatRunningDeploymentsOutput(count($horizonJobIds), $deploymentDetails, $showAll); exit(1); } +function formatRunningDeploymentsOutput(int $activeDeploymentCount, array $deploymentDetails = [], bool $showAll = false): string +{ + $output = "\n=== Running Deployments ===\n"; + $output .= 'Total active deployments: '.$activeDeploymentCount."\n"; + + if (! $showAll) { + return $output; + } + + $output .= "\n"; + + foreach ($deploymentDetails as $index => $deployment) { + $output .= 'Deployment #'.($index + 1).":\n"; + $output .= ' Application: '.$deployment['application_name']."\n"; + $output .= ' Server: '.$deployment['server_name']."\n"; + $output .= ' Started: '.$deployment['created_at']."\n"; + if ($deployment['deployment_url']) { + $output .= ' URL: '.$deployment['deployment_url']."\n"; + } + if (! empty($deployment['team_members'])) { + $output .= ' Team members: '.implode(', ', $deployment['team_members'])."\n"; + } else { + $output .= " Team members: No team members found\n"; + } + $output .= ' Horizon Job ID: '.$deployment['horizon_job_id']."\n\n"; + } + + return $output; +} + function isBase64Encoded($strValue) { return base64_encode(base64_decode($strValue, true)) === $strValue; @@ -2436,7 +2511,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } catch (Exception $e) { throw new RuntimeException($e->getMessage()); } - $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); $topLevelNetworks = collect(data_get($yaml, 'networks', [])); $topLevelConfigs = collect(data_get($yaml, 'configs', [])); @@ -2462,25 +2536,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { - // Workarounds for beta users. - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - // End of workarounds for beta users. + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $envComments) { + $predefinedPort = $resource->getRequiredPort(); $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3053,6 +3110,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($savedService, 'redirect') : 'both'; + $domainPortOverrides = $savedService instanceof ServiceApplication + ? ($savedService->domain_port_overrides ?? []) + : []; + $onlyPort = $savedService instanceof ServiceApplication + ? $savedService->getRequiredPort() + : $predefinedPort; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3065,8 +3128,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -3080,8 +3145,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -3095,8 +3163,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -3108,8 +3178,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -3808,31 +3881,36 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $fqdns = str($fqdns)->explode(','); if ($pull_request_id !== 0) { $preview = $resource->previews()->find($preview_id); + if (! $preview) { + try { + $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); + } catch (ModelNotFoundException) { + throw new RuntimeException('Preview not found.'); + } + } $docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []; if (count($docker_compose_domains) > 0) { $found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName); if ($found_fqdn) { - $fqdns = collect($found_fqdn); + $fqdns = str($found_fqdn)->explode(',')->map(fn ($fqdn) => trim($fqdn))->filter(); } else { $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pull_request_id, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $generatedOverrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->domain_port_overrides = array_replace( + $preview->domain_port_overrides ?? [], + $generatedOverrides, + ); + $preview->save(); } } $noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains(); @@ -3841,6 +3919,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $domainPortOverrides = $pull_request_id === 0 + ? ($resource->domain_port_overrides ?? []) + : ($preview?->domain_port_overrides ?? []); + $onlyPort = firstDockerComposeServicePort($service); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3854,8 +3936,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3870,8 +3954,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3887,8 +3973,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); $serviceLabels = $serviceLabels->merge( @@ -3901,8 +3989,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); } @@ -4152,6 +4242,8 @@ function coolifyHelperImage(): string function getHelperVersion(): string { + $configuredHelperVersion = config('constants.coolify.helper_version'); + if (isDev()) { $devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version'); @@ -4160,7 +4252,13 @@ function getHelperVersion(): string } } - return config('constants.coolify.helper_version'); + $fetchedHelperVersion = InstanceSettings::query()->whereKey(0)->value('helper_version'); + + if (! empty($fetchedHelperVersion) && version_compare($fetchedHelperVersion, $configuredHelperVersion, '>')) { + return $fetchedHelperVersion; + } + + return $configuredHelperVersion; } function loggy($message = null, array $context = []) @@ -4276,6 +4374,62 @@ NGINX; } } +/** + * Parse an scp-style SSH Git URL (`user@host:path` or `user@host:port/path`). + * + * @return array{user: string, host: string, port: ?string, path: string}|null + */ +function parseScpStyleGitUrl(?string $gitRepository): ?array +{ + if (! is_string($gitRepository) || $gitRepository === '') { + return null; + } + + if (preg_match('/^(?[A-Za-z0-9._-]+)@(?[^:]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { + return null; + } + + $host = trim($matches['host']); + $path = ltrim($matches['path'], '/'); + + if ($host === '' || $path === '') { + return null; + } + + return [ + 'user' => $matches['user'], + 'host' => $host, + 'port' => ($matches['port'] ?? '') === '' ? null : $matches['port'], + 'path' => $path, + ]; +} + +function scpStyleGitUrlToHttps(?string $gitRepository): ?string +{ + $parts = parseScpStyleGitUrl($gitRepository); + + if ($parts === null) { + return null; + } + + return 'https://'.$parts['host'].'/'.$parts['path']; +} + +function gitRepositorySlug(?string $gitRepository): string +{ + if (! is_string($gitRepository) || $gitRepository === '') { + return ''; + } + + if (($scp = parseScpStyleGitUrl($gitRepository)) !== null) { + $gitRepository = $scp['path']; + } elseif (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) { + $gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', ''); + } + + return str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString(); +} + function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array { $repository = $gitRepository; @@ -4286,7 +4440,6 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| 'repository' => $gitRepository, ]; $sshMatches = []; - $matches = []; // Let's try and parse the string to detect if it's a valid SSH string or not preg_match('/((.*?)\:\/\/)?(.*@.*:.*)/', $gitRepository, $sshMatches); @@ -4321,11 +4474,11 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| $providerInfo['port'] = (string) $parsedRepository['port']; } } else { - preg_match('/^(?[^:]+):(?\d+)\/(?.+)$/', $normalizedRepository, $matches); + $scp = parseScpStyleGitUrl($normalizedRepository); - if (! empty($matches['port'])) { - $providerInfo['port'] = $matches['port']; - $repository = "{$matches['host']}:{$matches['path']}"; + if ($scp !== null && $scp['port'] !== null) { + $providerInfo['port'] = $scp['port']; + $repository = "{$scp['user']}@{$scp['host']}:{$scp['path']}"; } } @@ -4416,6 +4569,28 @@ function formatBytes(?int $bytes, int $precision = 2): string return round($value, $precision).' '.$units[$exponent]; } +/** + * Compact human-readable count (e.g. 26_360 -> "26.36k", 1_200_000 -> "1.2M"). + * Trailing zeros are trimmed so round values read cleanly ("1k", not "1.00k"). + * Used for the dense metric columns in the traffic-analytics lists. + */ +function compactNumber(?int $n): string +{ + $n = (int) $n; + + if ($n < 1000) { + return (string) $n; + } + + [$divisor, $suffix] = match (true) { + $n >= 1_000_000_000 => [1_000_000_000, 'B'], + $n >= 1_000_000 => [1_000_000, 'M'], + default => [1000, 'k'], + }; + + return rtrim(rtrim(number_format($n / $divisor, 2, '.', ''), '0'), '.').$suffix; +} + /** * Validates that a file path is safely within the /tmp/ directory. * Protects against unsafe parent directory paths by resolving the real path @@ -4797,3 +4972,412 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string return str($value)->value(); } + +/** + * Convert an ISO 3166-1 alpha-2 country code into its regional-indicator flag emoji. + * + * The input is case-insensitive (e.g. "us" and "US" both yield the United States flag). + * For null, empty, or otherwise invalid input (not exactly two ASCII letters) a neutral + * globe emoji is returned to represent an "Unknown" origin. + */ +function countryFlagEmoji(?string $a2): string +{ + $unknown = '🌐'; + + if (! is_string($a2)) { + return $unknown; + } + + $code = strtoupper(trim($a2)); + + if (preg_match('/^[A-Z]{2}$/', $code) !== 1) { + return $unknown; + } + + $flag = ''; + foreach (str_split($code) as $letter) { + $flag .= mb_chr(0x1F1E6 + (ord($letter) - ord('A')), 'UTF-8'); + } + + return $flag; +} + +/** + * Resolve an ISO 3166-1 alpha-2 code to a flag image URL (flagcdn.com). + * + * Emoji flags do not render on most Linux/Windows browsers, so the analytics + * views render an instead. Returns null for null/invalid codes so callers + * can fall back to a globe icon. + */ +function countryFlagUrl(?string $a2, string $size = '24x18'): ?string +{ + if (! is_string($a2)) { + return null; + } + + $code = strtolower(trim($a2)); + + if (preg_match('/^[a-z]{2}$/', $code) !== 1) { + return null; + } + + return "https://flagcdn.com/{$size}/{$code}.png"; +} + +/** + * Extract the bare host from a referer value (full URL or bare host), dropping + * a leading "www.". Returns null when there is no usable host (e.g. direct hits). + */ +function refererHost(?string $referer): ?string +{ + if (! is_string($referer) || trim($referer) === '') { + return null; + } + + $referer = trim($referer); + $withScheme = str_contains($referer, '://') ? $referer : 'http://'.$referer; + $host = parse_url($withScheme, PHP_URL_HOST) ?: null; + + if (! $host) { + return null; + } + + $host = strtolower($host); + + return str_starts_with($host, 'www.') ? substr($host, 4) : $host; +} + +/** + * Group referrer breakdown rows by hostname and sum their metrics. + * + * @param array $rows + * @return array + */ +function groupRefererBreakdownRows(array $rows): array +{ + $grouped = []; + + foreach ($rows as $row) { + $value = (string) ($row['value'] ?? ''); + $host = $value === '__other__' ? $value : (refererHost($value) ?? $value); + + $grouped[$host] ??= ['value' => $host, 'requests' => 0, 'bytesOut' => 0]; + $grouped[$host]['requests'] += (int) ($row['requests'] ?? 0); + $grouped[$host]['bytesOut'] += (int) ($row['bytesOut'] ?? 0); + } + + $rows = array_values($grouped); + usort($rows, fn (array $left, array $right): int => $right['requests'] <=> $left['requests']); + + return $rows; +} + +/** + * Favicon URL for a host, served by DuckDuckGo's icon proxy. Used to decorate + * referrer rows in analytics. + * + * Note: rendering these icons makes the operator's browser request each favicon + * from icons.duckduckgo.com, which discloses the referrer hostnames of the + * operator's own traffic to that third party. Same applies to countryFlagUrl() + * (flagcdn.com). No API key is required. + */ +function refererFaviconUrl(string $host): string +{ + return 'https://icons.duckduckgo.com/ip3/'.rawurlencode($host).'.ico'; +} + +/** + * Map Sentinel's lowercase woothee device category to a friendly, capitalized + * label (e.g. "pc" -> "Desktop", "smartphone" -> "Mobile"). + */ +function deviceLabel(?string $device): string +{ + $value = strtolower(trim((string) $device)); + + return match ($value) { + '' => 'Unknown', + 'pc' => 'Desktop', + 'smartphone' => 'Mobile', + 'mobilephone' => 'Mobile', + 'appliance' => 'Appliance', + 'crawler' => 'Bot', + default => Str::title($value), + }; +} + +/** + * Resolve an ISO 3166-1 alpha-2 country code to its English country name. + * + * Uses a bundled ISO 3166-1 lookup so the result is deterministic and does not + * depend on the intl extension being installed. Returns "Unknown" for null, + * empty, invalid, or unassigned codes. + */ +function countryName(?string $a2): string +{ + $unknown = 'Unknown'; + + if (! is_string($a2)) { + return $unknown; + } + + $code = strtoupper(trim($a2)); + + if (preg_match('/^[A-Z]{2}$/', $code) !== 1) { + return $unknown; + } + + static $names = [ + 'AD' => 'Andorra', + 'AE' => 'United Arab Emirates', + 'AF' => 'Afghanistan', + 'AG' => 'Antigua & Barbuda', + 'AI' => 'Anguilla', + 'AL' => 'Albania', + 'AM' => 'Armenia', + 'AO' => 'Angola', + 'AQ' => 'Antarctica', + 'AR' => 'Argentina', + 'AS' => 'American Samoa', + 'AT' => 'Austria', + 'AU' => 'Australia', + 'AW' => 'Aruba', + 'AX' => 'Åland Islands', + 'AZ' => 'Azerbaijan', + 'BA' => 'Bosnia & Herzegovina', + 'BB' => 'Barbados', + 'BD' => 'Bangladesh', + 'BE' => 'Belgium', + 'BF' => 'Burkina Faso', + 'BG' => 'Bulgaria', + 'BH' => 'Bahrain', + 'BI' => 'Burundi', + 'BJ' => 'Benin', + 'BL' => 'St. Barthélemy', + 'BM' => 'Bermuda', + 'BN' => 'Brunei', + 'BO' => 'Bolivia', + 'BQ' => 'Caribbean Netherlands', + 'BR' => 'Brazil', + 'BS' => 'Bahamas', + 'BT' => 'Bhutan', + 'BV' => 'Bouvet Island', + 'BW' => 'Botswana', + 'BY' => 'Belarus', + 'BZ' => 'Belize', + 'CA' => 'Canada', + 'CC' => 'Cocos (Keeling) Islands', + 'CD' => 'Congo - Kinshasa', + 'CF' => 'Central African Republic', + 'CG' => 'Congo - Brazzaville', + 'CH' => 'Switzerland', + 'CI' => 'Côte d’Ivoire', + 'CK' => 'Cook Islands', + 'CL' => 'Chile', + 'CM' => 'Cameroon', + 'CN' => 'China', + 'CO' => 'Colombia', + 'CR' => 'Costa Rica', + 'CU' => 'Cuba', + 'CV' => 'Cape Verde', + 'CW' => 'Curaçao', + 'CX' => 'Christmas Island', + 'CY' => 'Cyprus', + 'CZ' => 'Czechia', + 'DE' => 'Germany', + 'DJ' => 'Djibouti', + 'DK' => 'Denmark', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'DZ' => 'Algeria', + 'EC' => 'Ecuador', + 'EE' => 'Estonia', + 'EG' => 'Egypt', + 'EH' => 'Western Sahara', + 'ER' => 'Eritrea', + 'ES' => 'Spain', + 'ET' => 'Ethiopia', + 'FI' => 'Finland', + 'FJ' => 'Fiji', + 'FK' => 'Falkland Islands', + 'FM' => 'Micronesia', + 'FO' => 'Faroe Islands', + 'FR' => 'France', + 'GA' => 'Gabon', + 'GB' => 'United Kingdom', + 'GD' => 'Grenada', + 'GE' => 'Georgia', + 'GF' => 'French Guiana', + 'GG' => 'Guernsey', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GL' => 'Greenland', + 'GM' => 'Gambia', + 'GN' => 'Guinea', + 'GP' => 'Guadeloupe', + 'GQ' => 'Equatorial Guinea', + 'GR' => 'Greece', + 'GS' => 'South Georgia & South Sandwich Islands', + 'GT' => 'Guatemala', + 'GU' => 'Guam', + 'GW' => 'Guinea-Bissau', + 'GY' => 'Guyana', + 'HK' => 'Hong Kong SAR China', + 'HM' => 'Heard & McDonald Islands', + 'HN' => 'Honduras', + 'HR' => 'Croatia', + 'HT' => 'Haiti', + 'HU' => 'Hungary', + 'ID' => 'Indonesia', + 'IE' => 'Ireland', + 'IL' => 'Israel', + 'IM' => 'Isle of Man', + 'IN' => 'India', + 'IO' => 'British Indian Ocean Territory', + 'IQ' => 'Iraq', + 'IR' => 'Iran', + 'IS' => 'Iceland', + 'IT' => 'Italy', + 'JE' => 'Jersey', + 'JM' => 'Jamaica', + 'JO' => 'Jordan', + 'JP' => 'Japan', + 'KE' => 'Kenya', + 'KG' => 'Kyrgyzstan', + 'KH' => 'Cambodia', + 'KI' => 'Kiribati', + 'KM' => 'Comoros', + 'KN' => 'St. Kitts & Nevis', + 'KP' => 'North Korea', + 'KR' => 'South Korea', + 'KW' => 'Kuwait', + 'KY' => 'Cayman Islands', + 'KZ' => 'Kazakhstan', + 'LA' => 'Laos', + 'LB' => 'Lebanon', + 'LC' => 'St. Lucia', + 'LI' => 'Liechtenstein', + 'LK' => 'Sri Lanka', + 'LR' => 'Liberia', + 'LS' => 'Lesotho', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'LV' => 'Latvia', + 'LY' => 'Libya', + 'MA' => 'Morocco', + 'MC' => 'Monaco', + 'MD' => 'Moldova', + 'ME' => 'Montenegro', + 'MF' => 'St. Martin', + 'MG' => 'Madagascar', + 'MH' => 'Marshall Islands', + 'MK' => 'North Macedonia', + 'ML' => 'Mali', + 'MM' => 'Myanmar (Burma)', + 'MN' => 'Mongolia', + 'MO' => 'Macao SAR China', + 'MP' => 'Northern Mariana Islands', + 'MQ' => 'Martinique', + 'MR' => 'Mauritania', + 'MS' => 'Montserrat', + 'MT' => 'Malta', + 'MU' => 'Mauritius', + 'MV' => 'Maldives', + 'MW' => 'Malawi', + 'MX' => 'Mexico', + 'MY' => 'Malaysia', + 'MZ' => 'Mozambique', + 'NA' => 'Namibia', + 'NC' => 'New Caledonia', + 'NE' => 'Niger', + 'NF' => 'Norfolk Island', + 'NG' => 'Nigeria', + 'NI' => 'Nicaragua', + 'NL' => 'Netherlands', + 'NO' => 'Norway', + 'NP' => 'Nepal', + 'NR' => 'Nauru', + 'NU' => 'Niue', + 'NZ' => 'New Zealand', + 'OM' => 'Oman', + 'PA' => 'Panama', + 'PE' => 'Peru', + 'PF' => 'French Polynesia', + 'PG' => 'Papua New Guinea', + 'PH' => 'Philippines', + 'PK' => 'Pakistan', + 'PL' => 'Poland', + 'PM' => 'St. Pierre & Miquelon', + 'PN' => 'Pitcairn Islands', + 'PR' => 'Puerto Rico', + 'PS' => 'Palestinian Territories', + 'PT' => 'Portugal', + 'PW' => 'Palau', + 'PY' => 'Paraguay', + 'QA' => 'Qatar', + 'RE' => 'Réunion', + 'RO' => 'Romania', + 'RS' => 'Serbia', + 'RU' => 'Russia', + 'RW' => 'Rwanda', + 'SA' => 'Saudi Arabia', + 'SB' => 'Solomon Islands', + 'SC' => 'Seychelles', + 'SD' => 'Sudan', + 'SE' => 'Sweden', + 'SG' => 'Singapore', + 'SH' => 'St. Helena', + 'SI' => 'Slovenia', + 'SJ' => 'Svalbard & Jan Mayen', + 'SK' => 'Slovakia', + 'SL' => 'Sierra Leone', + 'SM' => 'San Marino', + 'SN' => 'Senegal', + 'SO' => 'Somalia', + 'SR' => 'Suriname', + 'SS' => 'South Sudan', + 'ST' => 'São Tomé & Príncipe', + 'SV' => 'El Salvador', + 'SX' => 'Sint Maarten', + 'SY' => 'Syria', + 'SZ' => 'Eswatini', + 'TC' => 'Turks & Caicos Islands', + 'TD' => 'Chad', + 'TF' => 'French Southern Territories', + 'TG' => 'Togo', + 'TH' => 'Thailand', + 'TJ' => 'Tajikistan', + 'TK' => 'Tokelau', + 'TL' => 'Timor-Leste', + 'TM' => 'Turkmenistan', + 'TN' => 'Tunisia', + 'TO' => 'Tonga', + 'TR' => 'Türkiye', + 'TT' => 'Trinidad & Tobago', + 'TV' => 'Tuvalu', + 'TW' => 'Taiwan', + 'TZ' => 'Tanzania', + 'UA' => 'Ukraine', + 'UG' => 'Uganda', + 'UM' => 'U.S. Outlying Islands', + 'US' => 'United States', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VA' => 'Vatican City', + 'VC' => 'St. Vincent & Grenadines', + 'VE' => 'Venezuela', + 'VG' => 'British Virgin Islands', + 'VI' => 'U.S. Virgin Islands', + 'VN' => 'Vietnam', + 'VU' => 'Vanuatu', + 'WF' => 'Wallis & Futuna', + 'WS' => 'Samoa', + 'XK' => 'Kosovo', + 'YE' => 'Yemen', + 'YT' => 'Mayotte', + 'ZA' => 'South Africa', + 'ZM' => 'Zambia', + 'ZW' => 'Zimbabwe', + ]; + + return $names[$code] ?? $unknown; +} diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index 397efc387c..98dbe3af7a 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -59,13 +59,18 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array return $line; } + // Negation belongs to the shell, before the elevated command. + if (preg_match('/^\s*!\s+/', $line)) { + return preg_replace('/^(\s*(?:!\s+)+)/', '$1sudo ', $line); + } + // Check all keywords with word boundary matching // Match keyword followed by space, semicolon, or end of line foreach ($bashKeywords as $keyword) { if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) { - // Special handling for 'if' - insert sudo after 'if ' + // Keep any shell negation before sudo in the condition. if ($keyword === 'if') { - return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line); + return preg_replace('/^(\s*if\s+(?:!\s+)*)/', '$1sudo ', $line); } return $line; diff --git a/bun.lock b/bun.lock index cbe08fb954..8083b14d6b 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@tailwindcss/typography": "0.5.20", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "cobe": "^2.0.1", "playwright": "^1.58.2", "tw-animate-css": "^1.4.0", }, @@ -109,6 +110,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cobe": ["cobe@2.0.1", "", {}, "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], diff --git a/composer.json b/composer.json index 5f51f4da1a..d5b56e3c15 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "laravel/mcp": "^0.6.7", "laravel/nightwatch": "^1.28.6", "laravel/pail": "^1.2.7", - "laravel/prompts": "^0.3.22|^0.3.22|^0.3.22", + "laravel/prompts": "^0.3.22", "laravel/reverb": "^1.10", "laravel/sanctum": "^4.3.3", "laravel/socialite": "^5.29.0", diff --git a/composer.lock b/composer.lock index aa82aa12e8..f7ac6316d4 100644 --- a/composer.lock +++ b/composer.lock @@ -3900,16 +3900,16 @@ }, { "name": "livewire/livewire", - "version": "v3.8.3", + "version": "v3.8.7", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2" + "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2", - "reference": "ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ff019f8f6f48b7a2315922e45a70ad8fd75d1934", + "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934", "shasum": "" }, "require": { @@ -3964,7 +3964,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.8.3" + "source": "https://github.com/livewire/livewire/tree/v3.8.7" }, "funding": [ { @@ -3972,7 +3972,7 @@ "type": "github" } ], - "time": "2026-07-31T00:08:18+00:00" + "time": "2026-08-31T15:40:46+00:00" }, { "name": "log1x/laravel-webfonts", diff --git a/config/constants.php b/config/constants.php index 44c2e7c7f6..9ca003f3b0 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,8 +2,8 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.11', - 'helper_version' => '1.0.15', + 'version' => env('COOLIFY_VERSION') ?: '4.4', + 'helper_version' => '1.0.17', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), diff --git a/config/logging.php b/config/logging.php index 89c9d38dde..40d372b58d 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,6 +133,14 @@ return [ 'days' => 14, ], + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => env('LOG_AUDIT_LEVEL', 'info'), + 'days' => env('LOG_AUDIT_DAYS', 90), + 'replace_placeholders' => true, + ], + ], ]; diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php index 01ddebbd2b..b33eddbbbb 100644 --- a/database/factories/AuditEventFactory.php +++ b/database/factories/AuditEventFactory.php @@ -20,6 +20,7 @@ class AuditEventFactory extends Factory 'event' => 'ui.application.updated', 'source' => 'ui', 'action' => 'updated', + 'level' => 'info', 'actor_type' => 'user', 'description' => 'Application updated', 'metadata' => [], diff --git a/database/factories/DnsProviderZoneFactory.php b/database/factories/DnsProviderZoneFactory.php new file mode 100644 index 0000000000..1b34aa5ffc --- /dev/null +++ b/database/factories/DnsProviderZoneFactory.php @@ -0,0 +1,20 @@ + IntegrationToken::factory(), 'provider_zone_id' => fake()->uuid(), + 'name' => fake()->unique()->domainName(), 'account_id' => fake()->uuid(), 'account_name' => fake()->company(), + ]; + } +} diff --git a/database/factories/IntegrationTokenFactory.php b/database/factories/IntegrationTokenFactory.php new file mode 100644 index 0000000000..b78f932947 --- /dev/null +++ b/database/factories/IntegrationTokenFactory.php @@ -0,0 +1,20 @@ + Team::factory(), 'provider' => 'cloudflare', 'name' => fake()->words(2, true), + 'token' => fake()->sha256(), 'capabilities' => ['dns'], + ]; + } +} diff --git a/database/factories/ManagedDnsRecordFactory.php b/database/factories/ManagedDnsRecordFactory.php new file mode 100644 index 0000000000..e4c163036d --- /dev/null +++ b/database/factories/ManagedDnsRecordFactory.php @@ -0,0 +1,22 @@ + DnsProviderZone::factory(), + 'integration_token_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integration_token_id, + 'team_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integrationToken->team_id, + 'provider_record_id' => fake()->uuid(), 'type' => 'A', 'name' => fake()->domainName(), 'content' => fake()->ipv4(), + ]; + } +} diff --git a/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php b/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php new file mode 100644 index 0000000000..535469ab8b --- /dev/null +++ b/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php @@ -0,0 +1,44 @@ +boolean('is_traffic_analytics_enabled')->default(false); + $table->text('geoip_maxmind_license_key')->nullable(); + $table->integer('traffic_topn')->default(50); + $table->integer('traffic_sample_threshold')->default(0); + $table->integer('traffic_retention_1h_days')->default(30); + $table->integer('traffic_retention_1d_days')->default(395); + $table->boolean('is_geoip_enabled')->default(true); + $table->integer('geoip_refresh_days')->default(30); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn([ + 'is_traffic_analytics_enabled', + 'geoip_maxmind_license_key', + 'traffic_topn', + 'traffic_sample_threshold', + 'traffic_retention_1h_days', + 'traffic_retention_1d_days', + 'is_geoip_enabled', + 'geoip_refresh_days', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php b/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php new file mode 100644 index 0000000000..fe7c1e6292 --- /dev/null +++ b/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php @@ -0,0 +1,28 @@ +unsignedInteger('missing_backup_notification_days')->default(0); + $table->timestamp('missing_backup_notification_sent_at')->nullable(); + $table->timestamp('last_execution_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->dropColumn([ + 'missing_backup_notification_days', + 'missing_backup_notification_sent_at', + 'last_execution_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php b/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php new file mode 100644 index 0000000000..690e535311 --- /dev/null +++ b/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->string('provider_zone_id'); + $table->string('name'); + $table->string('account_id')->nullable(); + $table->string('account_name')->nullable(); + $table->timestamps(); + $table->unique(['integration_token_id', 'provider_zone_id']); + $table->index('name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('dns_provider_zones'); + } +}; diff --git a/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php b/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php new file mode 100644 index 0000000000..6fbcba6089 --- /dev/null +++ b/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->foreignId('dns_provider_zone_id')->constrained()->cascadeOnDelete(); + $table->nullableMorphs('resource'); + $table->string('provider_record_id'); + $table->string('type', 16); + $table->string('name'); + $table->string('content'); + $table->timestamps(); + $table->unique(['dns_provider_zone_id', 'provider_record_id']); + $table->index(['team_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('managed_dns_records'); + } +}; diff --git a/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php b/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php new file mode 100644 index 0000000000..01f53d4221 --- /dev/null +++ b/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('current_team_id')->nullable()->after('id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('current_team_id'); + }); + } +}; diff --git a/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php new file mode 100644 index 0000000000..fd1865ed32 --- /dev/null +++ b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_dns_statuses')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_dns_statuses'); + }); + } +}; diff --git a/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php b/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php new file mode 100644 index 0000000000..fc59fb587b --- /dev/null +++ b/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php @@ -0,0 +1,28 @@ +boolean('container_present')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('container_present'); + }); + } +}; diff --git a/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php b/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php new file mode 100644 index 0000000000..80603ee978 --- /dev/null +++ b/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php @@ -0,0 +1,37 @@ +boolean('restart_limit_reached')->default(false)->after('max_restart_count'); + }); + + DB::table('applications') + ->where('status', 'like', 'exited%') + ->where('restart_count', '>', 0) + ->where('max_restart_count', '>', 0) + ->whereColumn('restart_count', '>=', 'max_restart_count') + ->where('last_restart_type', 'crash') + ->update(['restart_limit_reached' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached'); + }); + } +}; diff --git a/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php b/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php new file mode 100644 index 0000000000..adf119a6e0 --- /dev/null +++ b/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php b/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php new file mode 100644 index 0000000000..0838ad4bd7 --- /dev/null +++ b/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php b/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php new file mode 100644 index 0000000000..046b02960c --- /dev/null +++ b/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php b/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php new file mode 100644 index 0000000000..641da5b759 --- /dev/null +++ b/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_postgresqls', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php b/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php new file mode 100644 index 0000000000..24329da9f3 --- /dev/null +++ b/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_redis', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php b/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php new file mode 100644 index 0000000000..08980a7cb8 --- /dev/null +++ b/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mongodbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php b/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php new file mode 100644 index 0000000000..729f852739 --- /dev/null +++ b/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mysqls', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php b/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php new file mode 100644 index 0000000000..6bada23268 --- /dev/null +++ b/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mariadbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php b/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php new file mode 100644 index 0000000000..41a983924b --- /dev/null +++ b/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_keydbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php b/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php new file mode 100644 index 0000000000..23d8ccf2c0 --- /dev/null +++ b/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_dragonflies', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php b/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php new file mode 100644 index 0000000000..5ec8d4522c --- /dev/null +++ b/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php b/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php new file mode 100644 index 0000000000..e4db4b7e8c --- /dev/null +++ b/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_email_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_email_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php b/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php new file mode 100644 index 0000000000..b7803e6335 --- /dev/null +++ b/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_discord_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('discord_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_discord_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php b/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php new file mode 100644 index 0000000000..51880ceb81 --- /dev/null +++ b/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php @@ -0,0 +1,30 @@ +boolean('restart_limit_reached_telegram_notifications')->default(true); + $table->text('telegram_notifications_restart_limit_reached_thread_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_telegram_notifications'); + $table->dropColumn('telegram_notifications_restart_limit_reached_thread_id'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php b/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php new file mode 100644 index 0000000000..0b82b423f8 --- /dev/null +++ b/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_slack_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('slack_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_slack_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php b/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php new file mode 100644 index 0000000000..596af0b5cc --- /dev/null +++ b/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_pushover_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('pushover_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_pushover_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php b/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php new file mode 100644 index 0000000000..cc03cf0f52 --- /dev/null +++ b/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_webhook_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_webhook_notifications'); + }); + } +}; diff --git a/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php new file mode 100644 index 0000000000..c51551e792 --- /dev/null +++ b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php new file mode 100644 index 0000000000..f208c5865f --- /dev/null +++ b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php new file mode 100644 index 0000000000..80fc8a0618 --- /dev/null +++ b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php new file mode 100644 index 0000000000..710578fd92 --- /dev/null +++ b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php @@ -0,0 +1,56 @@ +dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } + + public function down(): void + { + foreach (self::STANDALONE_DATABASE_TABLES as $tableName) { + Schema::table($tableName, function (Blueprint $table) { + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } +}; diff --git a/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php new file mode 100644 index 0000000000..1c898dd5da --- /dev/null +++ b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php @@ -0,0 +1,28 @@ +string('image_cdn_url')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('image_cdn_url'); + }); + } +}; diff --git a/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php b/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php new file mode 100644 index 0000000000..3c557fe446 --- /dev/null +++ b/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php @@ -0,0 +1,31 @@ +where('is_sentinel_enabled', false) + ->where('is_build_server', false) + ->where('is_swarm_manager', false) + ->where('is_swarm_worker', false) + ->where('force_disabled', false) + ->where('is_reachable', true) + ->where('is_usable', true) + ->update(['is_sentinel_enabled' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Existing values cannot be distinguished from values enabled before this migration. + } +}; diff --git a/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php b/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php new file mode 100644 index 0000000000..30acb09200 --- /dev/null +++ b/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php @@ -0,0 +1,21 @@ +whereNotNull('custom_internal_name') + ->where('custom_internal_name', '!=', '') + ->where('is_consistent_container_name_enabled', false) + ->update(['is_consistent_container_name_enabled' => true]); + } +}; diff --git a/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php new file mode 100644 index 0000000000..d49c1d57aa --- /dev/null +++ b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php @@ -0,0 +1,18 @@ +string('custom_container_name_prefix')->nullable(); + }); + } +}; diff --git a/database/migrations/2026_09_15_105627_make_restart_limits_opt_in.php b/database/migrations/2026_09_15_105627_make_restart_limits_opt_in.php new file mode 100644 index 0000000000..9c1c927c01 --- /dev/null +++ b/database/migrations/2026_09_15_105627_make_restart_limits_opt_in.php @@ -0,0 +1,47 @@ +integer('max_restart_count')->default(0)->change(); + }); + + DB::table($tableName) + ->select('id') + ->where('max_restart_count', 10) + ->chunkById(5000, function ($resources) use ($tableName): void { + DB::table($tableName) + ->whereIn('id', $resources->pluck('id')) + ->update([ + 'max_restart_count' => 0, + 'restart_limit_reached' => false, + ]); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (['applications', 'application_previews', 'service_applications'] as $tableName) { + Schema::table($tableName, function (Blueprint $table) { + $table->integer('max_restart_count')->default(10)->change(); + }); + } + } +}; diff --git a/database/migrations/2026_09_16_102243_add_sentinel_waiting_since_to_servers_table.php b/database/migrations/2026_09_16_102243_add_sentinel_waiting_since_to_servers_table.php new file mode 100644 index 0000000000..cf3bfe6e6f --- /dev/null +++ b/database/migrations/2026_09_16_102243_add_sentinel_waiting_since_to_servers_table.php @@ -0,0 +1,28 @@ +timestamp('sentinel_waiting_since')->nullable()->after('sentinel_updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('sentinel_waiting_since'); + }); + } +}; diff --git a/database/migrations/2026_09_18_111936_create_scheduled_job_states_and_deliveries_tables.php b/database/migrations/2026_09_18_111936_create_scheduled_job_states_and_deliveries_tables.php new file mode 100644 index 0000000000..6057056351 --- /dev/null +++ b/database/migrations/2026_09_18_111936_create_scheduled_job_states_and_deliveries_tables.php @@ -0,0 +1,49 @@ +id(); + $table->string('uuid')->unique(); + $table->string('schedule_key')->unique(); + $table->timestampTz('last_scheduled_for')->nullable(); + $table->timestamps(); + }); + + Schema::create('scheduled_job_deliveries', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('schedule_key'); + $table->timestampTz('scheduled_for'); + $table->string('job_type'); + $table->unsignedBigInteger('resource_id'); + $table->json('payload')->nullable(); + $table->string('status')->default('pending'); + $table->string('claim_token')->nullable(); + $table->timestampTz('enqueued_at')->nullable(); + $table->timestampTz('started_at')->nullable(); + $table->timestamps(); + + $table->unique(['schedule_key', 'scheduled_for']); + $table->index(['status', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('scheduled_job_deliveries'); + Schema::dropIfExists('scheduled_job_states'); + } +}; diff --git a/database/migrations/2026_09_18_165916_add_is_build_server_fallback_enabled_to_teams_table.php b/database/migrations/2026_09_18_165916_add_is_build_server_fallback_enabled_to_teams_table.php new file mode 100644 index 0000000000..4b103d4263 --- /dev/null +++ b/database/migrations/2026_09_18_165916_add_is_build_server_fallback_enabled_to_teams_table.php @@ -0,0 +1,28 @@ +boolean('is_build_server_fallback_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropColumn('is_build_server_fallback_enabled'); + }); + } +}; diff --git a/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php b/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php new file mode 100644 index 0000000000..4c982a6a49 --- /dev/null +++ b/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php @@ -0,0 +1,28 @@ +string('server_role')->nullable()->default(ServerRole::BOTH->value)->after('is_build_server'); + }); + + DB::table('server_settings') + ->where('is_build_server', true) + ->update(['server_role' => ServerRole::BUILD->value]); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('server_role'); + }); + } +}; diff --git a/database/migrations/2026_09_21_070754_remove_duplicate_additional_destinations.php b/database/migrations/2026_09_21_070754_remove_duplicate_additional_destinations.php new file mode 100644 index 0000000000..e5e0b7d441 --- /dev/null +++ b/database/migrations/2026_09_21_070754_remove_duplicate_additional_destinations.php @@ -0,0 +1,37 @@ +select([ + 'application_id', + 'server_id', + 'standalone_docker_id', + DB::raw('MIN(id) as first_id'), + ]) + ->groupBy('application_id', 'server_id', 'standalone_docker_id') + ->havingRaw('COUNT(*) > 1') + ->get() + ->each(function (object $duplicate): void { + DB::table('additional_destinations') + ->where('application_id', $duplicate->application_id) + ->where('server_id', $duplicate->server_id) + ->where('standalone_docker_id', $duplicate->standalone_docker_id) + ->where('id', '!=', $duplicate->first_id) + ->delete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void {} +}; diff --git a/database/migrations/2026_09_21_070755_add_unique_index_to_additional_destinations_table.php b/database/migrations/2026_09_21_070755_add_unique_index_to_additional_destinations_table.php new file mode 100644 index 0000000000..1a7746f7c3 --- /dev/null +++ b/database/migrations/2026_09_21_070755_add_unique_index_to_additional_destinations_table.php @@ -0,0 +1,31 @@ +unique( + ['application_id', 'server_id', 'standalone_docker_id'], + 'additional_destinations_application_server_docker_unique' + ); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('additional_destinations', function (Blueprint $table) { + $table->dropUnique('additional_destinations_application_server_docker_unique'); + }); + } +}; diff --git a/database/migrations/2026_09_21_100148_add_level_to_audit_events_table.php b/database/migrations/2026_09_21_100148_add_level_to_audit_events_table.php new file mode 100644 index 0000000000..c6ffdb57cd --- /dev/null +++ b/database/migrations/2026_09_21_100148_add_level_to_audit_events_table.php @@ -0,0 +1,28 @@ +string('level', 16)->default('info')->after('action')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('audit_events', function (Blueprint $table) { + $table->dropColumn('level'); + }); + } +}; diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php index ebae97078b..fd1f8fb09e 100644 --- a/database/seeders/SentinelSeeder.php +++ b/database/seeders/SentinelSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Jobs\CheckAndStartSentinelJob; use App\Models\Server; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Log; @@ -13,6 +14,10 @@ class SentinelSeeder extends Seeder Server::chunk(100, function ($servers) { foreach ($servers as $server) { try { + if ($server->isSentinelEnabled()) { + $server->settings->is_sentinel_enabled = true; + $server->settings->saveQuietly(); + } if (str($server->settings->sentinel_token)->isEmpty()) { $server->settings->generateSentinelToken(ignoreEvent: true); } @@ -25,11 +30,10 @@ class SentinelSeeder extends Seeder } if (str($server->settings->sentinel_custom_url)->isEmpty()) { - $url = $server->settings->generateSentinelUrl(ignoreEvent: true); - if (str($url)->isEmpty()) { - $server->settings->is_sentinel_enabled = false; - $server->settings->save(); - } + $server->settings->generateSentinelUrl(ignoreEvent: true); + } + if ($server->isFunctional() && $server->isSentinelEnabled() && filled($server->settings->sentinel_custom_url)) { + CheckAndStartSentinelJob::dispatch($server); } } catch (\Throwable $e) { Log::error('Error seeding sentinel: '.$e->getMessage()); diff --git a/database/seeders/TeamSeeder.php b/database/seeders/TeamSeeder.php index 67c5ec4897..08426044c6 100644 --- a/database/seeders/TeamSeeder.php +++ b/database/seeders/TeamSeeder.php @@ -10,14 +10,14 @@ class TeamSeeder extends Seeder { public function run(): void { - $normal_user_in_root_team = User::find(1); + $normal_user_in_root_team = User::where('email', 'test2@example.com')->firstOrFail(); $root_user_personal_team = Team::find(0); $root_user_personal_team->description = 'The root team'; $root_user_personal_team->save(); $normal_user_in_root_team->teams()->attach($root_user_personal_team); - $normal_user_not_in_root_team = User::find(2); - $normal_user_in_root_team_personal_team = Team::find(1); + $normal_user_not_in_root_team = User::where('email', 'test3@example.com')->firstOrFail(); + $normal_user_in_root_team_personal_team = $normal_user_in_root_team->teams()->where('personal_team', true)->wherePivot('role', 'owner')->firstOrFail(); $normal_user_not_in_root_team->teams()->attach($normal_user_in_root_team_personal_team, ['role' => 'admin']); } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 19d3aa42e8..9f237dc3b5 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -22,5 +22,6 @@ class UserSeeder extends Seeder 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); + } } diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index eaddb91dda..3d4525510e 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -138,7 +138,7 @@ services: networks: - coolify # maxio-init: - # image: minio/mc:latest + # image: ghcr.io/coollabsio/mx:0.1.0 # pull_policy: always # container_name: coolify-maxio-init # restart: no @@ -158,7 +158,7 @@ services: # networks: # - coolify minio-init: - image: minio/mc:latest + image: ghcr.io/coollabsio/mx:0.1.0 pull_policy: always container_name: coolify-minio-init restart: no diff --git a/docker-compose.dev-multi.yml b/docker-compose.dev-multi.yml index d9d414b8ab..26ba2236b6 100644 --- a/docker-compose.dev-multi.yml +++ b/docker-compose.dev-multi.yml @@ -170,7 +170,7 @@ services: minio-init: profiles: ["minio"] - image: minio/mc:latest + image: ghcr.io/coollabsio/mx:0.1.0 pull_policy: always restart: "no" depends_on: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9a53202fd7..6210d70bd4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -126,7 +126,7 @@ services: networks: - coolify minio-init: - image: minio/mc:latest + image: ghcr.io/coollabsio/mx:0.1.0 pull_policy: always container_name: coolify-minio-init restart: no diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index aef073d7d5..3962b2b078 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -103,7 +103,6 @@ services: retries: 10 timeout: 2s - volumes: coolify-db: name: coolify-db diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 94330bbcec..1fe99f01c2 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -15,11 +15,7 @@ ARG NIXPACKS_VERSION=1.41.0 ARG RAILPACK_VERSION=0.23.0 # https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) ARG MISE_VERSION=2026.3.17 -# https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z - - -FROM minio/mc:${MINIO_VERSION} AS minio-client +FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client FROM ${BASE_IMAGE} AS base diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index f42328897c..00a9dd9590 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -8,8 +8,11 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, + sanitizeSshArgs, + validateSshArgs, } from './terminal-utils.js'; async function postToCoolify(path, headers) { @@ -384,12 +387,22 @@ async function handleCommand(ws, command, userId) { return; } + if (!validateSshArgs(sshArgs, userSession.authorizedIPs)) { + logTerminal('warn', 'Rejecting terminal command because its SSH arguments are not allowed.', { + userId, + targetHost, + }); + ws.send('Invalid SSH command: Unsupported SSH arguments'); + return; + } + const sanitizedSshArgs = sanitizeSshArgs(sshArgs); + const options = { name: 'xterm-color', cols: 80, rows: 30, cwd: process.env.HOME, - env: {}, + env: getTerminalProcessEnv(), }; // NOTE: - Initiates a process within the Terminal container @@ -401,7 +414,7 @@ async function handleCommand(ws, command, userId) { commandTimeout, terminalSessionTimeout, }); - const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); + const ptyProcess = pty.spawn('ssh', sanitizedSshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; userSession.isActive = true; diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index 61f82f6265..c2762f1d85 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -1,5 +1,13 @@ export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; +const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + +export function getTerminalProcessEnv(environment = process.env) { + return { + PATH: environment.PATH || DEFAULT_TERMINAL_PATH, + }; +} + export function getTerminalSessionTimeout() { return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; } @@ -131,3 +139,133 @@ export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) { .map(host => normalizeHostForAuthorization(host)) .includes(normalizedTargetHost); } + +const REQUIRED_SSH_OPTIONS = new Set([ + 'StrictHostKeyChecking', + 'UserKnownHostsFile', + 'PasswordAuthentication', + 'ConnectTimeout', + 'ServerAliveInterval', + 'RequestTTY', + 'LogLevel', +]); + +function isAllowedSshOption(name, value) { + const fixedOptions = { + StrictHostKeyChecking: 'no', + UserKnownHostsFile: '/dev/null', + PasswordAuthentication: 'no', + LogLevel: 'ERROR', + ControlMaster: 'auto', + ProxyCommand: 'cloudflared access ssh --hostname %h', + }; + + if (Object.hasOwn(fixedOptions, name)) { + return value === fixedOptions[name]; + } + + if (name === 'RequestTTY') { + return value === 'yes' || value === 'no'; + } + + if (name === 'ConnectTimeout' || name === 'ServerAliveInterval' || name === 'ControlPersist') { + return /^\d+$/.test(value) && Number(value) > 0; + } + + if (name === 'ControlPath') { + return /^\/var\/www\/html\/storage\/app\/ssh\/mux\/mux_[a-zA-Z0-9_-]+$/.test(value); + } + + return false; +} + +export function validateSshArgs(sshArgs, authorizedHosts = []) { + if (!Array.isArray(sshArgs) || sshArgs.length === 0) { + return false; + } + + const seenOptions = new Set(); + let hasIdentityFile = false; + let hasPort = false; + let targetHost = null; + + for (let index = 0; index < sshArgs.length; index++) { + const argument = sshArgs[index]; + + if (typeof argument !== 'string' || /[\0\r\n]/.test(argument)) { + return false; + } + + if (argument === '-i') { + const identityFile = sshArgs[++index]; + if (hasIdentityFile || !/^\/var\/www\/html\/storage\/app\/ssh\/keys\/ssh_key@[a-zA-Z0-9_-]+$/.test(identityFile ?? '')) { + return false; + } + hasIdentityFile = true; + continue; + } + + if (argument === '-p') { + const port = sshArgs[++index]; + if (hasPort || !/^\d+$/.test(port ?? '') || Number(port) < 1 || Number(port) > 65535) { + return false; + } + hasPort = true; + continue; + } + + if (argument === '-o') { + const option = sshArgs[++index]; + const separator = option?.indexOf('=') ?? -1; + if (separator < 1) { + return false; + } + + const name = option.slice(0, separator); + const value = option.slice(separator + 1); + if (seenOptions.has(name) || !isAllowedSshOption(name, value)) { + return false; + } + seenOptions.add(name); + continue; + } + + if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) { + targetHost = extractTargetHost([argument]); + continue; + } + + return false; + } + + const hasRequiredOptions = [...REQUIRED_SSH_OPTIONS].every(option => seenOptions.has(option)); + const hasCompleteMultiplexingOptions = + !['ControlMaster', 'ControlPath', 'ControlPersist'].some(option => seenOptions.has(option)) + || ['ControlMaster', 'ControlPath', 'ControlPersist'].every(option => seenOptions.has(option)); + + return hasIdentityFile + && hasPort + && targetHost !== null + && hasRequiredOptions + && hasCompleteMultiplexingOptions + && isAuthorizedTargetHost(targetHost, authorizedHosts); +} + +export function sanitizeSshArgs(sshArgs) { + const multiplexingOptions = new Set(['ControlMaster', 'ControlPath', 'ControlPersist']); + const sanitizedArgs = []; + + for (let index = 0; index < sshArgs.length; index++) { + if (sshArgs[index] === '-o') { + const optionName = sshArgs[index + 1]?.split('=', 1)[0]; + if (multiplexingOptions.has(optionName)) { + index++; + continue; + } + } + + sanitizedArgs.push(sshArgs[index]); + } + + return sanitizedArgs; +} diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index d3b639ba5f..e9acda3270 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -4,11 +4,40 @@ import { MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, + sanitizeSshArgs, + validateSshArgs, } from './terminal-utils.js'; +test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '/usr/local/bin:/usr/bin:/bin', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/bin:/usr/bin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => { + assert.deepEqual(getTerminalProcessEnv({ + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { const sshArgs = extractSshArgs( "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" @@ -56,6 +85,79 @@ test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); }); +test('validateSshArgs accepts the SSH arguments generated by Coolify', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p '22' 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true); +}); + +test('validateSshArgs rejects an injected ProxyCommand', () => { + const sshArgs = extractSshArgs( + "timeout 300 ssh -o 'ProxyCommand=/bin/busybox id >/tmp/marker' root@10.0.0.5 'bash -se' << \\ENDSSH\nENDSSH" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false); +}); + +test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => { + const validArgs = extractSshArgs( + "timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@example.com 'bash -se' << \\$abc\necho hi\nabc" + ); + const maliciousArgs = [...validArgs]; + maliciousArgs[1] = 'ProxyCommand=cloudflared access ssh --hostname %h; id'; + + assert.equal(validateSshArgs(validArgs, ['example.com']), true); + assert.equal(validateSshArgs(maliciousArgs, ['example.com']), false); +}); + +test('validateSshArgs rejects unknown SSH options and key paths', () => { + const baseArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-i', '/tmp/attacker-key', ...baseArgs.slice(2)], ['10.0.0.5']), false); +}); + +test('validateSshArgs rejects a destination that begins with an option prefix', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false); +}); + +test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true); + assert.deepEqual(sanitizeSshArgs(sshArgs), [ + '-i', + '/var/www/html/storage/app/ssh/keys/ssh_key@cm123', + '-o', + 'StrictHostKeyChecking=no', + '-o', + 'UserKnownHostsFile=/dev/null', + '-o', + 'PasswordAuthentication=no', + '-o', + 'ConnectTimeout=10', + '-o', + 'ServerAliveInterval=20', + '-o', + 'RequestTTY=yes', + '-o', + 'LogLevel=ERROR', + '-p', + '22', + 'root@10.0.0.5', + ]); +}); + test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => { assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index f88b71a7eb..6297da2cfe 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -1,8 +1,6 @@ # Versions # https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine -# https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z # https://github.com/cloudflare/cloudflared/releases ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ @@ -14,7 +12,7 @@ ARG NGINX_VERSION=1.31.2-r1 # ================================================================= # Get MinIO client # ================================================================= -FROM minio/mc:${MINIO_VERSION} AS minio-client +FROM ghcr.io/coollabsio/mx:0.1.0 AS minio-client # ================================================================= # Final Stage: Production image diff --git a/docker/development/etc/nginx/conf.d/custom.conf b/docker/development/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/development/etc/nginx/conf.d/custom.conf +++ b/docker/development/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index d9d872b83c..03d1145df7 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -1,8 +1,6 @@ # Versions # https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine -# https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z # https://github.com/cloudflare/cloudflared/releases ARG CLOUDFLARED_VERSION=2026.7.3 # https://www.postgresql.org/support/versioning/ @@ -63,7 +61,7 @@ RUN npm run build # ================================================================= # Stage 3: Get MinIO client # ================================================================= -FROM minio/mc:${MINIO_VERSION} AS minio-client +FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client # ================================================================= # Final Stage: Production image diff --git a/docker/production/etc/nginx/conf.d/custom.conf b/docker/production/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/production/etc/nginx/conf.d/custom.conf +++ b/docker/production/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/openapi.json b/openapi.json index fcc9f34db4..964b50fe61 100644 --- a/openapi.json +++ b/openapi.json @@ -11,6 +11,66 @@ } ], "paths": { + "\/applications\/{uuid}\/secret-manager": { + "patch": { + "tags": [ + "Secret Managers" + ], + "summary": "Configure Application Secret Manager", + "description": "Configure the secret manager source used by an application.", + "operationId": "configure-application-secret-manager", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "integration_token_uuid" + ], + "properties": { + "integration_token_uuid": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Secret manager configured." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications": { "get": { "tags": [ @@ -3508,6 +3568,273 @@ ] } }, + "\/applications\/{uuid}\/previews\/{pull_request_id}\/logs": { + "get": { + "tags": [ + "Applications" + ], + "summary": "Get preview application logs.", + "description": "Get runtime container logs for a preview deployment by application UUID and pull request ID.", + "operationId": "get-preview-application-logs-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "description": "Pull request ID of the preview deployment.", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.", + "required": false, + "schema": { + "oneOf": [ + { + "type": "integer", + "format": "int32", + "default": 100, + "maximum": 10000, + "minimum": -1 + }, + { + "type": "string", + "enum": [ + "all" + ] + } + ] + } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Preview runtime logs.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/previews\/{pull_request_id}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Preview Deployment", + "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", + "operationId": "delete-preview-deployment-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "description": "Pull request ID of the preview to delete.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Preview deletion queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Preview Domains", + "description": "Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.", + "operationId": "update-preview-domains-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "domains": { + "type": [ + "string", + "null" + ], + "example": "https:\/\/pr.example.com:3000" + }, + "docker_compose_domains": { + "type": [ + "array", + "null" + ], + "items": { + "properties": { + "name": { + "type": "string" + }, + "domain": { + "type": [ + "string", + "null" + ] + }, + "redirect": { + "type": [ + "string", + "null" + ], + "enum": [ + "www", + "non-www", + "both" + ] + } + }, + "type": "object" + } + }, + "force_domain_override": { + "type": "boolean", + "default": false + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Preview domains updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "$ref": "#\/components\/responses\/403" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflict." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications\/{uuid}\/envs": { "get": { "tags": [ @@ -4345,11 +4672,6 @@ "type": "string", "description": "The container mount path." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, optional)." - }, "content": { "type": "string", "nullable": true, @@ -4456,11 +4778,6 @@ "type": "string", "description": "The container mount path (not allowed for read-only storages)." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, not allowed for read-only storages)." - }, "content": { "type": "string", "nullable": true, @@ -4568,70 +4885,6 @@ ] } }, - "\/applications\/{uuid}\/previews\/{pull_request_id}": { - "delete": { - "tags": [ - "Applications" - ], - "summary": "Delete Preview Deployment", - "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", - "operationId": "delete-preview-deployment-by-pull-request-id", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "pull_request_id", - "in": "path", - "description": "Pull request ID of the preview to delete.", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Preview deletion queued.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string" - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "400": { - "$ref": "#\/components\/responses\/400" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/applications\/{uuid}\/tags": { "get": { "tags": [ @@ -5778,6 +6031,134 @@ ] } }, + "\/databases\/{uuid}\/imports\/uploads": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Upload database import", + "operationId": "upload-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Upload completed" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/imports": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Import database backup", + "operationId": "create-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Import queued" + }, + "409": { + "description": "Import already active" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/imports\/{activity_id}": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get database import status", + "operationId": "get-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "activity_id", + "in": "path", + "description": "Import activity ID.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Import status", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportStatus" + } + } + } + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases": { "get": { "tags": [ @@ -5946,6 +6327,13 @@ "type": "integer", "description": "Backup job timeout in seconds (min: 60, max: 36000)", "default": 3600 + }, + "missing_backup_notification_days": { + "type": "integer", + "description": "Alert after this many days without an execution; 0 disables alerts", + "minimum": 0, + "maximum": 365, + "default": 0 } }, "type": "object" @@ -6545,6 +6933,12 @@ "type": "integer", "description": "Backup job timeout in seconds (min: 60, max: 36000)", "default": 3600 + }, + "missing_backup_notification_days": { + "type": "integer", + "description": "Alert after this many days without an execution; 0 disables alerts", + "minimum": 0, + "maximum": 365 } }, "type": "object" @@ -8769,11 +9163,6 @@ "type": "string", "description": "The container mount path." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, optional)." - }, "content": { "type": "string", "nullable": true, @@ -8880,11 +9269,6 @@ "type": "string", "description": "The container mount path (not allowed for read-only storages)." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, not allowed for read-only storages)." - }, "content": { "type": "string", "nullable": true, @@ -11689,13 +12073,129 @@ ] } }, + "\/settings\/email": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get instance email settings", + "description": "Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.", + "operationId": "get-instance-email-settings", + "responses": { + "200": { + "description": "Instance email settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Settings" + ], + "summary": "Update instance email settings", + "description": "Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.", + "operationId": "update-instance-email-settings", + "responses": { + "200": { + "description": "Updated instance email settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/security\/integration-tokens": { + "post": { + "tags": [ + "Secret Managers" + ], + "summary": "Create Secret Manager Token", + "description": "Create and validate a Doppler, Infisical, or Vault integration token.", + "operationId": "create-secret-manager-integration-token", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "provider", + "name", + "token" + ], + "properties": { + "provider": { + "type": "string", + "enum": [ + "doppler", + "infisical", + "vault" + ] + }, + "name": { + "type": "string" + }, + "token": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Integration token created." + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/notifications\/email": { "get": { "tags": [ "Notifications" ], "summary": "Get email notification settings", - "description": "Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "description": "Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", "operationId": "get-current-team-email-notifications", "responses": { "200": { @@ -11719,7 +12219,7 @@ "Notifications" ], "summary": "Update email notification settings", - "description": "Update the current team email notification settings.", + "description": "Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.", "operationId": "update-current-team-email-notifications", "responses": { "200": { @@ -15563,6 +16063,28 @@ "string", "null" ] + }, + "traffic_topn": { + "type": "integer" + }, + "traffic_sample_threshold": { + "type": "integer" + }, + "traffic_retention_1h_days": { + "type": "integer" + }, + "traffic_retention_1d_days": { + "type": "integer" + }, + "is_geoip_enabled": { + "type": "boolean" + }, + "geoip_refresh_days": { + "type": "integer" + }, + "geoip_maxmind_license_key": { + "description": "Only present with read:sensitive.", + "type": "string" } }, "type": "object" @@ -15639,6 +16161,35 @@ "string", "null" ] + }, + "traffic_topn": { + "type": "integer", + "minimum": 1 + }, + "traffic_sample_threshold": { + "type": "integer", + "minimum": 0 + }, + "traffic_retention_1h_days": { + "type": "integer", + "minimum": 1 + }, + "traffic_retention_1d_days": { + "type": "integer", + "minimum": 1 + }, + "is_geoip_enabled": { + "type": "boolean" + }, + "geoip_refresh_days": { + "type": "integer", + "minimum": 1 + }, + "geoip_maxmind_license_key": { + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -16816,6 +17367,20 @@ "boolean", "null" ] + }, + "max_restart_count": { + "description": "Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.", + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "is_force_https_enabled": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -17203,6 +17768,161 @@ ] } }, + "\/services\/{uuid}\/databases\/{database_uuid}\/imports\/uploads": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Upload service database import", + "operationId": "upload-service-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Upload completed" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/imports": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Import service database backup", + "operationId": "create-service-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Import queued" + }, + "409": { + "description": "Import already active" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/imports\/{activity_id}": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "Get service database import status", + "operationId": "get-service-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "activity_id", + "in": "path", + "description": "Import activity ID.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Import status", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportStatus" + } + } + } + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/databases": { "get": { "tags": [ @@ -19148,11 +19868,6 @@ "type": "string", "description": "The container mount path." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, optional)." - }, "content": { "type": "string", "nullable": true, @@ -19259,11 +19974,6 @@ "type": "string", "description": "The container mount path (not allowed for read-only storages)." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, not allowed for read-only storages)." - }, "content": { "type": "string", "nullable": true, @@ -21402,6 +22112,145 @@ }, "components": { "schemas": { + "DatabaseImportRequest": { + "type": "object", + "oneOf": [ + { + "required": [ + "source", + "upload_id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "upload" + ] + }, + "upload_id": { + "type": "string", + "format": "uuid" + }, + "dump_all": { + "type": "boolean", + "default": false + }, + "replace_existing": { + "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", + "type": "boolean", + "default": false + } + }, + "type": "object", + "additionalProperties": false + }, + { + "required": [ + "source", + "s3_storage_uuid", + "path" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "s3" + ] + }, + "s3_storage_uuid": { + "type": "string" + }, + "path": { + "type": "string" + }, + "dump_all": { + "type": "boolean", + "default": false + }, + "replace_existing": { + "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", + "type": "boolean", + "default": false + } + }, + "type": "object", + "additionalProperties": false + }, + { + "required": [ + "source", + "path" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "server" + ] + }, + "path": { + "type": "string", + "example": "\/var\/backups\/database.sql.gz" + }, + "dump_all": { + "type": "boolean", + "default": false + }, + "replace_existing": { + "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", + "type": "boolean", + "default": false + } + }, + "type": "object", + "additionalProperties": false + } + ] + }, + "DatabaseImportStatus": { + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "in_progress", + "finished", + "error", + "killed", + "cancelled", + "closed" + ] + }, + "exit_code": { + "type": [ + "integer", + "null" + ] + }, + "output": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "type": "object" + }, "VolumeBackupScheduleRequest": { "required": [ "frequency" @@ -21474,7 +22323,7 @@ }, "timeout": { "type": "integer", - "default": 3600, + "default": 36000, "maximum": 36000, "minimum": 60 } @@ -22520,6 +23369,9 @@ "deployment_queue_limit": { "type": "integer" }, + "backup_compression_cpu_percentage": { + "type": "integer" + }, "dynamic_timeout": { "type": "integer" }, @@ -22553,6 +23405,27 @@ "is_metrics_enabled": { "type": "boolean" }, + "is_traffic_analytics_enabled": { + "type": "boolean" + }, + "traffic_topn": { + "type": "integer" + }, + "traffic_sample_threshold": { + "type": "integer" + }, + "traffic_retention_1h_days": { + "type": "integer" + }, + "traffic_retention_1d_days": { + "type": "integer" + }, + "is_geoip_enabled": { + "type": "boolean" + }, + "geoip_refresh_days": { + "type": "integer" + }, "is_reachable": { "type": "boolean" }, @@ -22630,6 +23503,26 @@ "connection_timeout": { "type": "integer", "description": "SSH connection timeout in seconds." + }, + "docker_version": { + "type": "string", + "nullable": true, + "description": "Detected Docker Engine version on the server." + }, + "docker_version_checked_at": { + "type": "string", + "nullable": true, + "description": "When Docker Engine version was last detected." + }, + "compose_version": { + "type": "string", + "nullable": true, + "description": "Detected Docker Compose plugin version on the server." + }, + "compose_version_checked_at": { + "type": "string", + "nullable": true, + "description": "When Docker Compose version was last detected." } }, "type": "object" @@ -22969,6 +23862,10 @@ } }, "tags": [ + { + "name": "Secret Managers", + "description": "Secret Managers" + }, { "name": "Applications", "description": "Applications" @@ -23009,6 +23906,10 @@ "name": "Hetzner", "description": "Hetzner" }, + { + "name": "Settings", + "description": "Settings" + }, { "name": "Notifications", "description": "Notifications" diff --git a/openapi.yaml b/openapi.yaml index c4cea3fc4c..b0843f9194 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7,6 +7,45 @@ servers: url: 'https://app.coolify.io/api/v1' description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.' paths: + '/applications/{uuid}/secret-manager': + patch: + tags: + - 'Secret Managers' + summary: 'Configure Application Secret Manager' + description: 'Configure the secret manager source used by an application.' + operationId: configure-application-secret-manager + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - integration_token_uuid + properties: + integration_token_uuid: + type: string + settings: + type: object + type: object + responses: + '200': + description: 'Secret manager configured.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /applications: get: tags: @@ -2307,6 +2346,167 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/previews/{pull_request_id}/logs': + get: + tags: + - Applications + summary: 'Get preview application logs.' + description: 'Get runtime container logs for a preview deployment by application UUID and pull request ID.' + operationId: get-preview-application-logs-by-pull-request-id + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: pull_request_id + in: path + description: 'Pull request ID of the preview deployment.' + required: true + schema: + type: integer + minimum: 1 + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.' + required: false + schema: + oneOf: + - + type: integer + format: int32 + default: 100 + maximum: 10000 + minimum: -1 + - + type: string + enum: + - all + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Preview runtime logs.' + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/previews/{pull_request_id}': + delete: + tags: + - Applications + summary: 'Delete Preview Deployment' + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.' + operationId: delete-preview-deployment-by-pull-request-id + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: pull_request_id + in: path + description: 'Pull request ID of the preview to delete.' + required: true + schema: + type: integer + responses: + '200': + description: 'Preview deletion queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Applications + summary: 'Update Preview Domains' + description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.' + operationId: update-preview-domains-by-pull-request-id + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: pull_request_id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + properties: + domains: + type: [string, 'null'] + example: 'https://pr.example.com:3000' + docker_compose_domains: + type: [array, 'null'] + items: { properties: { name: { type: string }, domain: { type: [string, 'null'] }, redirect: { type: [string, 'null'], enum: [www, non-www, both] } }, type: object } + force_domain_override: + type: boolean + default: false + type: object + responses: + '200': + description: 'Preview domains updated.' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Domain conflict.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/applications/{uuid}/envs': get: tags: @@ -2828,10 +3028,6 @@ paths: mount_path: type: string description: 'The container mount path.' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, optional).' content: type: string nullable: true @@ -2904,10 +3100,6 @@ paths: mount_path: type: string description: 'The container mount path (not allowed for read-only storages).' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, not allowed for read-only storages).' content: type: string nullable: true @@ -2974,48 +3166,6 @@ paths: security: - bearerAuth: [] - '/applications/{uuid}/previews/{pull_request_id}': - delete: - tags: - - Applications - summary: 'Delete Preview Deployment' - description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.' - operationId: delete-preview-deployment-by-pull-request-id - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - - - name: pull_request_id - in: path - description: 'Pull request ID of the preview to delete.' - required: true - schema: - type: integer - responses: - '200': - description: 'Preview deletion queued.' - content: - application/json: - schema: - properties: - message: { type: string } - type: object - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] '/applications/{uuid}/tags': get: tags: @@ -3720,6 +3870,91 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/imports/uploads': + post: + tags: + - Databases + summary: 'Upload database import' + operationId: upload-database-import + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '201': + description: 'Upload completed' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/imports': + post: + tags: + - Databases + summary: 'Import database backup' + operationId: create-database-import + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportRequest' + responses: + '202': + description: 'Import queued' + '409': + description: 'Import already active' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/imports/{activity_id}': + get: + tags: + - Databases + summary: 'Get database import status' + operationId: get-database-import + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: activity_id + in: path + description: 'Import activity ID.' + required: true + schema: + type: integer + responses: + '200': + description: 'Import status' + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportStatus' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /databases: get: tags: @@ -3843,6 +4078,12 @@ paths: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' default: 3600 + missing_backup_notification_days: + type: integer + description: 'Alert after this many days without an execution; 0 disables alerts' + minimum: 0 + maximum: 365 + default: 0 type: object responses: '201': @@ -4262,6 +4503,11 @@ paths: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' default: 3600 + missing_backup_notification_days: + type: integer + description: 'Alert after this many days without an execution; 0 disables alerts' + minimum: 0 + maximum: 365 type: object responses: '200': @@ -5745,10 +5991,6 @@ paths: mount_path: type: string description: 'The container mount path.' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, optional).' content: type: string nullable: true @@ -5821,10 +6063,6 @@ paths: mount_path: type: string description: 'The container mount path (not allowed for read-only storages).' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, not allowed for read-only storages).' content: type: string nullable: true @@ -7490,12 +7728,86 @@ paths: security: - bearerAuth: [] + /settings/email: + get: + tags: + - Settings + summary: 'Get instance email settings' + description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.' + operationId: get-instance-email-settings + responses: + '200': + description: 'Instance email settings.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + security: + - + bearerAuth: [] + patch: + tags: + - Settings + summary: 'Update instance email settings' + description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.' + operationId: update-instance-email-settings + responses: + '200': + description: 'Updated instance email settings.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /security/integration-tokens: + post: + tags: + - 'Secret Managers' + summary: 'Create Secret Manager Token' + description: 'Create and validate a Doppler, Infisical, or Vault integration token.' + operationId: create-secret-manager-integration-token + requestBody: + required: true + content: + application/json: + schema: + required: + - provider + - name + - token + properties: + provider: + type: string + enum: [doppler, infisical, vault] + name: + type: string + token: + type: string + metadata: + type: object + type: object + responses: + '201': + description: 'Integration token created.' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /notifications/email: get: tags: - Notifications summary: 'Get email notification settings' - description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' operationId: get-current-team-email-notifications responses: '200': @@ -7511,7 +7823,7 @@ paths: tags: - Notifications summary: 'Update email notification settings' - description: 'Update the current team email notification settings.' + description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.' operationId: update-current-team-email-notifications responses: '200': @@ -9872,6 +10184,13 @@ paths: sentinel_push_interval_seconds: { type: integer } sentinel_custom_url: { description: 'Only present with read:sensitive.', type: string } sentinel_updated_at: { type: [string, 'null'] } + traffic_topn: { type: integer } + traffic_sample_threshold: { type: integer } + traffic_retention_1h_days: { type: integer } + traffic_retention_1d_days: { type: integer } + is_geoip_enabled: { type: boolean } + geoip_refresh_days: { type: integer } + geoip_maxmind_license_key: { description: 'Only present with read:sensitive.', type: string } type: object '401': $ref: '#/components/responses/401' @@ -9921,6 +10240,25 @@ paths: minimum: 10 sentinel_custom_url: type: [string, 'null'] + traffic_topn: + type: integer + minimum: 1 + traffic_sample_threshold: + type: integer + minimum: 0 + traffic_retention_1h_days: + type: integer + minimum: 1 + traffic_retention_1d_days: + type: integer + minimum: 1 + is_geoip_enabled: + type: boolean + geoip_refresh_days: + type: integer + minimum: 1 + geoip_maxmind_license_key: + type: [string, 'null'] type: object responses: '200': @@ -10658,6 +10996,12 @@ paths: type: [boolean, 'null'] is_stripprefix_enabled: type: [boolean, 'null'] + max_restart_count: + description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.' + type: [integer, 'null'] + minimum: 0 + is_force_https_enabled: + type: [boolean, 'null'] type: object responses: '200': @@ -10909,6 +11253,112 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/imports/uploads': + post: + tags: + - 'Service databases' + summary: 'Upload service database import' + operationId: upload-service-database-import + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + responses: + '201': + description: 'Upload completed' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/imports': + post: + tags: + - 'Service databases' + summary: 'Import service database backup' + operationId: create-service-database-import + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportRequest' + responses: + '202': + description: 'Import queued' + '409': + description: 'Import already active' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}': + get: + tags: + - 'Service databases' + summary: 'Get service database import status' + operationId: get-service-database-import + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + - + name: activity_id + in: path + description: 'Import activity ID.' + required: true + schema: + type: integer + responses: + '200': + description: 'Import status' + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportStatus' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/services/{uuid}/databases': get: tags: @@ -12099,10 +12549,6 @@ paths: mount_path: type: string description: 'The container mount path.' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, optional).' content: type: string nullable: true @@ -12175,10 +12621,6 @@ paths: mount_path: type: string description: 'The container mount path (not allowed for read-only storages).' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, not allowed for read-only storages).' content: type: string nullable: true @@ -13601,6 +14043,106 @@ paths: bearerAuth: [] components: schemas: + DatabaseImportRequest: + type: object + oneOf: + - + required: + - source + - upload_id + properties: + source: + type: string + enum: + - upload + upload_id: + type: string + format: uuid + dump_all: + type: boolean + default: false + replace_existing: + description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' + type: boolean + default: false + type: object + additionalProperties: false + - + required: + - source + - s3_storage_uuid + - path + properties: + source: + type: string + enum: + - s3 + s3_storage_uuid: + type: string + path: + type: string + dump_all: + type: boolean + default: false + replace_existing: + description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' + type: boolean + default: false + type: object + additionalProperties: false + - + required: + - source + - path + properties: + source: + type: string + enum: + - server + path: + type: string + example: /var/backups/database.sql.gz + dump_all: + type: boolean + default: false + replace_existing: + description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' + type: boolean + default: false + type: object + additionalProperties: false + DatabaseImportStatus: + properties: + id: + type: integer + status: + type: string + enum: + - queued + - in_progress + - finished + - error + - killed + - cancelled + - closed + exit_code: + type: + - integer + - 'null' + output: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + finished_at: + type: + - string + - 'null' + format: date-time + type: object VolumeBackupScheduleRequest: required: - frequency @@ -13659,7 +14201,7 @@ components: minimum: 0 timeout: type: integer - default: 3600 + default: 36000 maximum: 36000 minimum: 60 type: object @@ -14429,6 +14971,8 @@ components: type: integer deployment_queue_limit: type: integer + backup_compression_cpu_percentage: + type: integer dynamic_timeout: type: integer force_disabled: @@ -14451,6 +14995,20 @@ components: type: boolean is_metrics_enabled: type: boolean + is_traffic_analytics_enabled: + type: boolean + traffic_topn: + type: integer + traffic_sample_threshold: + type: integer + traffic_retention_1h_days: + type: integer + traffic_retention_1d_days: + type: integer + is_geoip_enabled: + type: boolean + geoip_refresh_days: + type: integer is_reachable: type: boolean is_sentinel_enabled: @@ -14504,6 +15062,22 @@ components: connection_timeout: type: integer description: 'SSH connection timeout in seconds.' + docker_version: + type: string + nullable: true + description: 'Detected Docker Engine version on the server.' + docker_version_checked_at: + type: string + nullable: true + description: 'When Docker Engine version was last detected.' + compose_version: + type: string + nullable: true + description: 'Detected Docker Compose plugin version on the server.' + compose_version_checked_at: + type: string + nullable: true + description: 'When Docker Compose version was last detected.' type: object Service: description: 'Service model' @@ -14733,6 +15307,9 @@ components: description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.' scheme: bearer tags: + - + name: 'Secret Managers' + description: 'Secret Managers' - name: Applications description: Applications @@ -14763,6 +15340,9 @@ tags: - name: Hetzner description: Hetzner + - + name: Settings + description: Settings - name: Notifications description: Notifications diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 7351944ba4..47d7f2d7de 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -102,7 +102,6 @@ services: retries: 10 timeout: 2s - volumes: coolify-db: name: coolify-db diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 0e62c0b7e4..7262de886a 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,27 +1,27 @@ { "coolify": { "v4": { - "version": "4.3.11" + "version": "4.4" }, "nightly": { - "version": "4.4-rc.1" + "version": "4.5-rc.1" }, "helper": { - "version": "1.0.15" + "version": "1.0.17" }, "sentinel": { - "version": "0.0.22" + "version": "1.0.1" } }, "traefik": { - "v3.7": "3.7.8", - "v3.6": "3.6.23", + "v3.7": "3.7.13", + "v3.6": "3.6.25", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.52" + "v2.11": "2.11.57" } } diff --git a/package-lock.json b/package-lock.json index d8cb35e61c..41c4740165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@tailwindcss/typography": "0.5.20", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "cobe": "^2.0.1", "playwright": "^1.58.2", "tw-animate-css": "^1.4.0" }, @@ -697,6 +698,12 @@ "node": ">=6" } }, + "node_modules/cobe": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cobe/-/cobe-2.0.1.tgz", + "integrity": "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index 42f39d29ea..a46a750d46 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@tailwindcss/typography": "0.5.20", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "cobe": "^2.0.1", "playwright": "^1.58.2", "tw-animate-css": "^1.4.0" } diff --git a/public/svgs/executor.png b/public/svgs/executor.png new file mode 100644 index 0000000000..a7cc57de9f Binary files /dev/null and b/public/svgs/executor.png differ diff --git a/resources/css/app.css b/resources/css/app.css index 30c9147f44..ac7c4ab041 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -20,51 +20,196 @@ --font-geist-sans: 'Geist Sans', Inter, sans-serif; --font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - --color-base: #101010; - --color-warning: #fcd452; - --color-warning-50: #fefce8; - --color-warning-100: #fef9c3; - --color-warning-200: #fef08a; - --color-warning-300: #fde047; - --color-warning-400: #fcd452; - --color-warning-500: #facc15; - --color-warning-600: #ca8a04; - --color-warning-700: #a16207; - --color-warning-800: #854d0e; - --color-warning-900: #713f12; - --color-success: #22C55E; - --color-error: #dc2626; - --color-coollabs-50: #f5f0ff; - --color-coollabs: #6b16ed; - --color-coollabs-100: #7317ff; - --color-coollabs-200: #5a12c7; - --color-coollabs-300: #4a0fa3; - --color-coolgray-100: #181818; - --color-coolgray-200: #202020; - --color-coolgray-300: #242424; - --color-coolgray-400: #282828; - --color-coolgray-500: #323232; + --color-base: oklch(17.3% 0 0); + --color-warning: oklch(88.13% 0.1507 91.7); + --color-warning-50: oklch(98.73% 0.0262 102.21); + --color-warning-100: oklch(97.29% 0.0693 103.19); + --color-warning-200: oklch(94.51% 0.1243 101.54); + --color-warning-300: oklch(90.52% 0.1657 98.11); + --color-warning-400: oklch(88.13% 0.1507 91.7); + --color-warning-500: oklch(86.06% 0.1731 91.94); + --color-warning-600: oklch(68.06% 0.1423 75.83); + --color-warning-700: oklch(55.38% 0.1207 66.44); + --color-warning-800: oklch(47.62% 0.1034 61.91); + --color-warning-900: oklch(42.1% 0.0897 57.71); + --color-success: oklch(72.27% 0.192 149.58); + --color-error: oklch(57.71% 0.2152 27.33); + --color-coollabs-50: oklch(96.33% 0.0206 301.15); + --color-coollabs: oklch(49.65% 0.2709 289.33); + --color-coollabs-100: oklch(52.34% 0.287 289.16); + --color-coollabs-200: oklch(43.76% 0.2369 289.82); + --color-coollabs-300: oklch(38.04% 0.2031 290.47); + --color-coolgray-100: oklch(20.9% 0 0); + --color-coolgray-200: oklch(24.35% 0 0); + --color-coolgray-300: oklch(26.03% 0 0); + --color-coolgray-400: oklch(27.68% 0 0); + --color-coolgray-500: oklch(31.71% 0 0); /* Graphite design language (ported from ref/frontend). Layered neutral surfaces + translucent hairlines. See DESIGN.md. */ - --color-app: #0c0c0d; - /* Canvas: neutral near-black (oklch), not pure black */ - --color-panel: oklch(10% 0 0); - --color-surface: #161618; - --color-raised: #1c1c1e; - --color-selected: #26262a; - --color-fg: #f2f2f2; - --color-fg-dim: #b4b4b8; - --color-fg-faint: #6e6e74; - --color-accent: #6b16ed; - --color-accent-foreground: #ffffff; - --color-hairline: rgba(255, 255, 255, 0.08); - --color-nav-text: #525252; - --color-nav-muted: #666666; - --color-nav-active: #171717; - --color-log: #0d0d0d; + /* Content canvas: the darkest shell layer (sRGB ~10). Hex, not oklch: + oklch lightness compresses to near-black below ~15%, so oklch(7.5%) + renders as sRGB 1 with no visible step. */ + --color-app: oklch(14.48% 0 0); + /* Sidebar + topbar chrome (sRGB ~20): a clear step lighter than the content + canvas so the shell chrome separates from the content area. */ + --color-panel: oklch(19.13% 0 0); + /* Pure neutral (r=g=b). Were cool-tinted (#161618/#1c1c1e/#26262a, b>r), + which clashed with the neutral panel ladder. */ + --color-surface: oklch(20.02% 0 0); + --color-raised: oklch(22.64% 0 0); + --color-selected: oklch(26.86% 0 0); + --color-fg: oklch(96.12% 0 0); + --color-fg-dim: oklch(76.99% 0 0); + /* Tertiary text. Raised from #6e6e74 (3.78:1 on base, failed WCAG AA) + to #7e7e84 so 13-14px text clears 4.5:1 on the dark surface ladder. */ + --color-fg-faint: oklch(59.31% 0 0); + --color-accent: oklch(49.65% 0.2709 289.33); + --color-accent-foreground: oklch(100.0% 0 0); + --color-hairline: oklch(100.0% 0 0 / 0.08); + --color-nav-text: oklch(43.86% 0 0); + --color-nav-muted: oklch(51.03% 0 0); + --color-nav-active: oklch(20.46% 0 0); + --color-log: oklch(15.91% 0 0); --shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.4); + /* One restrained lift shared by every dropdown/menu/listbox panel and the + command palette. Lighter and consistent, replacing the old scattered + 0.45 / 0.35 / shadow-lg / shadow-sm mix. Modals keep --shadow-modal. */ + --shadow-dropdown: 0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px rgb(0 0 0 / 0.08); + + /* Shared motion curves (stronger than the built-in CSS easings). Use these + for consistency: --ease-out for UI enter/press, --ease-drawer for + drawers/sheets, --ease-in-out for on-screen movement. */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); +} + +/* Standard buttons have a compact physical edge. Hover lifts the face by 1px; + pressing nudges it down while the depth disappears. */ +.button:not(:disabled) { + --button-depth-color: rgb(0 0 0 / 0.22); + --button-depth: 0 2px 0 var(--button-depth-color); + --button-depth-hover: 0 3px 0 var(--button-depth-color); + + box-shadow: var(--button-depth); +} + +.button.button-highlighted:not(:disabled), +.button[isHighlighted]:not(:disabled) { + --button-depth-color: var(--color-coollabs-300); +} + +.dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled) { + --button-depth-color: rgb(255 255 255 / 0.08); +} + +.button.button-error:not(:disabled) { + --button-depth-color: var(--color-red-300); +} + +.dark .button.button-error:not(:disabled) { + --button-depth-color: var(--color-red-800); +} + +/* Keep the shared focus indicator when the depth shadow owns box-shadow. */ +.button:not(:disabled):focus-visible { + box-shadow: var(--button-depth), 0 0 0 1px var(--color-accent); +} + +.button:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: var(--button-depth-hover); +} + +.button:not(:disabled):hover:focus-visible { + box-shadow: var(--button-depth-hover), 0 0 0 1px var(--color-accent); +} + +/* Other button-like controls retain the lighter scale feedback. */ +.button, +.icon-button, +.app-tab, +.split-action-main, +.split-action-caret { + transition-property: color, background-color, border-color, box-shadow, transform; + transition-timing-function: var(--ease-out); + transition-duration: 120ms; +} + +.button { + transition-duration: 120ms, 120ms, 120ms, 80ms, 80ms; +} + +.icon-button:not(:disabled):active, +.app-tab:active, +.split-action-main:not(:disabled):active, +.split-action-caret:not(:disabled):active { + transform: scale(0.97); +} + +.button:not(:disabled):active { + transform: translateY(2px); + box-shadow: none; +} + +/* Focus border/ring on any form control should ease, not snap. The accent + focus indicator is a border-color and/or box-shadow (ring) change, which the + utilities' `transition-colors` did not fully animate. Target the elements + themselves (covers bespoke search fields too) plus the div-based controls. */ +input, +textarea, +select, +.listbox-trigger, +.chip-input { + transition-property: color, background-color, border-color, box-shadow; + transition-timing-function: var(--ease-out); + transition-duration: 120ms; +} + +/* + Traffic-analytics chart tokens (light defaults; dark overrides below). + One source of truth shared by ApexCharts donuts, the geo choropleth SVG, + and proportional bars — JS reads them at runtime via getComputedStyle. + Palette validated with the dataviz skill; contrast ratios are recorded in + the PR description. See resources/views/livewire/traffic/_geo.blade.php. +*/ +:root { + /* Categorical HTTP status palette (labelled 2xx/3xx/4xx/5xx in every legend). */ + --chart-status-2xx: #15803d; + --chart-status-3xx: #2563eb; + --chart-status-4xx: #d97706; + --chart-status-5xx: #dc2626; + + /* KPI sparkline accent for Bandwidth (violet — distinct from the status hues). */ + --chart-spark-bandwidth: #7c3aed; + + /* Sequential 5-step geo ramp (low -> high traffic) + neutral empty. */ + --chart-geo-1: #3b82f6; + --chart-geo-2: #2563eb; + --chart-geo-3: #1d4ed8; + --chart-geo-4: #1e40af; + --chart-geo-5: #172554; + --chart-geo-empty: #e5e7eb; + --chart-geo-stroke: #ffffff; +} + +.dark { + --chart-status-2xx: #22c55e; + --chart-status-3xx: #3b82f6; + --chart-status-4xx: #f59e0b; + --chart-status-5xx: #ef4444; + + --chart-spark-bandwidth: #a78bfa; + + --chart-geo-1: #2563eb; + --chart-geo-2: #3b82f6; + --chart-geo-3: #60a5fa; + --chart-geo-4: #93c5fd; + --chart-geo-5: #bfdbfe; + --chart-geo-empty: #262626; + --chart-geo-stroke: #101010; } /* @@ -258,7 +403,7 @@ */ html, body { - @apply w-full min-h-full bg-gray-50 dark:bg-app dark:text-fg-dim; + @apply w-full min-h-full bg-neutral-50 dark:bg-app dark:text-fg-dim; } body { @@ -284,8 +429,16 @@ option { @apply dark:text-white dark:bg-coolgray-100; } -button[isError]:not(:disabled) { - @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white; +.button.button-error { + @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800; +} + +.button.button-error:disabled { + @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800; +} + +.button-error:not(:disabled) { + @apply hover:bg-red-100 hover:text-red-900 dark:hover:bg-red-700 dark:hover:text-white; } button[isHighlighted]:not(:disabled) { @@ -369,6 +522,11 @@ tr td:first-child { padding-right: 2.5rem; } +/* Room for both the peek eye and the copy button on maskable read-only fields. */ +.input.input-with-copy-and-peek { + padding-right: 4.25rem; +} + .lds-heart { animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); } @@ -505,25 +663,24 @@ html[data-theme="custom"] #nprogress .spinner-icon { .application-console-shell[data-console-theme="system"], .terminal-fullscreen-shell[data-console-theme="system"] { - --console-theme-background: #fff; - --console-theme-border: #d4d4d8; + --console-theme-background: oklch(100.0% 0 0); + --console-theme-border: oklch(87.11% 0.0055 286.29); --console-theme-opacity: 1; } -html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header { - background: transparent; - color: #52525b; - border-color: rgb(0 0 0 / 0.1); - backdrop-filter: none; -} - -html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] { +/* Session toolbar (selected-target trigger + label) sits over the white + system console in light mode. Without this its text-white/* stayed white on + white and the selected server was invisible (issue #11532). */ +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-toolbar[class*="text-white"], +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-toolbar [class*="text-white"], +html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-toolbar[class*="text-white"], +html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-toolbar [class*="text-white"] { color: #52525b !important; } html.dark .application-console-shell[data-console-theme="system"], html.dark .terminal-fullscreen-shell[data-console-theme="system"] { - --console-theme-background: #121214; + --console-theme-background: oklch(18.31% 0.004 285.99); --console-theme-border: rgb(255 255 255 / 0.08); } @@ -533,6 +690,19 @@ html[data-theme="custom"] .terminal-fullscreen-shell[data-console-theme="system" --console-theme-border: var(--coollabs-line); } +/* Docked System console only: the fill behind the terminal panel is the + elevated (header) surface, so the panel's rounded-corner notch reads as the + header colour — the card's figure/ground. The panel paints its own base + surface over this fill, so only the notch shows it. The fullscreen shell has + no panel, so it is intentionally excluded and keeps its plain terminal fill. */ +.application-console-shell[data-console-theme="system"] { + --console-theme-background: var(--coollabs-elevated); +} + +html.dark .application-console-shell[data-console-theme="system"] { + --console-theme-background: var(--coollabs-elevated); +} + .console-theme-selector { scrollbar-color: rgb(161 161 170) transparent; scrollbar-width: thin; @@ -656,6 +826,10 @@ html.dark .console-theme-selector::-webkit-scrollbar-thumb { flex: 1 1 0%; flex-direction: column; overflow: hidden; + /* Breathing room so the console's box-shadow ring + lift are not clipped by + this overflow-hidden, viewport-height container (the card gets this from + the scrollable page around it). */ + padding: 6px; } .terminal-page-console > .application-console-shell { @@ -712,11 +886,71 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too touch-action: manipulation; } -.application-console-header { +/* Session header band: an attached card-style header on top of the console, + mirroring the page card header (elevated surface + hairline divider). The + System theme uses the neutral card surface in both modes; colorful themes + fall back to a translucent dark band so the white controls stay legible. */ +.terminal-session-toolbar { position: relative; z-index: 2; - background: rgb(0 0 0 / 0.24); - backdrop-filter: blur(20px); + flex-shrink: 0; + min-height: 3rem; + padding: 0.5rem 0.75rem 0.5rem 1rem; + /* No border-radius: the shell is overflow-hidden + rounded and clips the + header's top corners, so rounding here only risks a sub-pixel seam. */ + border-bottom: 1px solid var(--terminal-header-border, rgb(255 255 255 / 0.1)); + background: var(--terminal-header-bg, rgb(0 0 0 / 0.28)); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +/* System header has no bottom border: the body panel's ring is the divider, and + its rounded top corners tuck under the header — the same merge the + target-picker card uses (elevated header meeting a base body panel). */ +.application-console-shell[data-console-theme="system"] .terminal-session-toolbar { + --terminal-header-bg: var(--coollabs-elevated); + --terminal-header-border: transparent; + /* Shell no longer clips (overflow: visible), so the header rounds its own + top corners — matching the card header. */ + border-radius: 8px 8px 0 0; + /* Drop the z-index:2 stacking context: it painted the header OVER the body + panel, hiding the panel's ring (the divider). The header only had z-2 to + beat ::before, but its dropdowns sit in their own .relative wrappers, so + z:auto is safe and lets the body panel's ring paint over the header — + exactly how the card body overlaps the card header. */ + z-index: auto; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* System console body: the terminal panel, a full rounded box-shadow ring like + the card body. Its base surface is the terminal fill; the elevated surface + behind it (::before) shows only through the rounded-corner notch, reading as + the header colour — the card's figure/ground. */ +.application-console-shell[data-console-theme="system"] .terminal-session-panel { + background: var(--coollabs-base); + /* The body panel is a full rounded ring (like the card body). position: + relative puts it (and its ring) above ::before AND, because it comes + after the now-z:auto header in the DOM, above the header — so the ring's + top edge is the divider and its rounded top corners connect down into the + side ring, which coincides with the shell's ring (one hairline, no + doubling). A box-shadow ring rounds and connects cleanly; a border-top + cannot (square corners, or a disconnected transparent edge). */ + position: relative; + border-radius: 8px; + box-shadow: 0 0 0 1px var(--coollabs-hairline); +} + +/* Connected System console frame = the target-picker card recipe: a hairline + box-shadow ring + a restrained lift. overflow stays visible so the ring is + not clipped (the .terminal-page-console padding gives it room inside the + viewport-locked, overflow-hidden page). Colorful themes keep the themed, + overflow-hidden shell. */ +.application-console-shell[data-console-theme="system"] { + overflow: visible; + box-shadow: + 0 0 0 1px var(--coollabs-hairline), + 0 1px 2px 0 rgb(0 0 0 / 0.05); } .terminal-loading-label { @@ -824,7 +1058,7 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina } html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-panel { - background: transparent; + background: var(--coollabs-base); } .terminal-target-list { @@ -851,7 +1085,7 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina color: #52525b; background: #fff; border-color: #d4d4d8; - box-shadow: 0 18px 50px rgb(0 0 0 / 0.18); + box-shadow: var(--shadow-dropdown); } html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker [class*="text-white"] { @@ -924,75 +1158,166 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina * utility classes placed in the card markup. */ :root { - --coollabs-canvas: oklch(98.75% 0 0); + /* Page canvas is a faint off-white so white/elevated cards lift off it + (shadcn pattern), instead of card and page reading as one flat color. */ + --coollabs-canvas: oklch(97% 0 0); --coollabs-elevated: oklch(98% 0 0); --coollabs-recessed: oklch(96% 0 0); - --coollabs-base: #ffffff; + --coollabs-base: oklch(100.0% 0 0); --coollabs-fill: oklch(92.2% 0 0); --coollabs-line: oklch(14.5% 0 0 / 0.1); - --coollabs-hairline: oklch(93.5% 0 0); - --coollabs-subtle: oklch(55.6% 0 0); + /* Card/table edge ring. Crisped from 93.5% (1.17:1 on canvas, invisible) + to 85.5% (1.51:1) so surfaces read as bordered cards, not flat fills. */ + --coollabs-hairline: oklch(85.5% 0 0); + /* Muted labels/titles. Darkened from 55.6% (failed WCAG AA on elevated + 4.46, recessed 4.24, fill 3.76) to 50% so muted text clears 4.5:1 on + every light surface above white. Dark stays 70.8% (already passes). */ + --coollabs-subtle: oklch(50% 0 0); } .dark { - --color-accent: #fcd452; - --coollabs-canvas: oklch(10% 0 0); - /* elevated (card shells/headers) and recessed (input fills) sit a touch - above the canvas so they read lighter than near-black */ - --coollabs-elevated: oklch(15% 0 0); - --coollabs-recessed: oklch(20% 0 0); - --coollabs-base: oklch(17% 0 0); - --coollabs-fill: oklch(26.9% 0 0); + --color-accent: oklch(88.13% 0.1507 91.7); + /* Dark surface ladder in hex (sRGB), not oklch: oklch lightness compresses + to near-black below ~15%, so oklch(15%) rendered as sRGB 11 and cards + could not lift off the content canvas (sRGB 10). These give clear, + visible steps: content 10 -> elevated 22 -> base 28 -> recessed 34. + Pure neutral (r=g=b), matching the neutral borders/text and the light + ladder, so every panel shares one temperature (no blue cast). */ + --coollabs-canvas: oklch(14.48% 0 0); + --coollabs-elevated: oklch(20.02% 0 0); + --coollabs-recessed: oklch(25.2% 0 0); + --coollabs-base: oklch(22.64% 0 0); + --coollabs-fill: oklch(29.31% 0 0); --coollabs-line: oklch(32% 0 0); - --coollabs-hairline: oklch(26.9% 0 0); + /* Crisped from 26.9% (1.36:1 on canvas) to 32% (1.63:1) so dark cards + and tables show a visible edge instead of blending into the canvas. */ + --coollabs-hairline: oklch(32% 0 0); --coollabs-subtle: oklch(70.8% 0 0); - --color-nav-text: #a8a8b0; - --color-nav-muted: #7a7a84; - --color-nav-active: #f2f2f2; + /* Neutral (was #a8a8b0 / #7a7a84, both cool-tinted). Sidebar text now shares + the same neutral temperature as the content text, so the sidebar no longer + reads cooler than the panels/cards. */ + --color-nav-text: oklch(73.8% 0 0); + /* Raised from 58.97% (4.47:1 on panel, just under AA) to 61% (4.86:1) so the + 11px sidebar section labels clear 4.5:1. */ + --color-nav-muted: oklch(61% 0 0); + --color-nav-active: oklch(96.12% 0 0); + /* Tertiary text. Base token (59.31%) only clears 4.5:1 on app/panel; it + failed on surface/raised/selected (4.46/4.20/3.73). Raised to 64% so the + 313 dark:text-fg-faint usages clear AA across the whole surface ladder, + while staying clearly dimmer than fg-dim (76.99%). */ + --color-fg-faint: oklch(65% 0 0); + /* Error text. The base error red (57.71%) failed on every dark surface + (4.10/3.75/3.53). Brightened for dark mode so text-error clears AA on the + card ladder while staying unmistakably red. */ + --color-error: oklch(66% 0.2 27.33); +} + +/* Light-mode semantic text. + warning / success / error double as the bright brand accent and as solid + fills, which only appear under `dark:` — so in light mode these tokens are + used almost entirely as text, translucent tints (bg-warning/10) and borders. + The bright values (yellow 88%, green 72%) and the base red (57.71%) fail AA + as text on white and the light surface ladder (warning 1.43, success 2.28, + error 4.43 on canvas). Scoped to `:not(.dark)` so dark keeps the brand + colors and the custom theme keeps its own overrides. Every value clears + 4.5:1 on white, canvas, elevated, recessed and fill, and is in sRGB gamut. */ +html:not(.dark) { + --color-warning: oklch(50% 0.105 80); + --color-success: oklch(48% 0.135 149.58); + --color-error: oklch(52% 0.2 27.33); } /* Theme surfaces are derived from one brand color. Changing --theme-base-color is enough to generate a complete dark surface ladder. */ html[data-theme="custom"] { - --theme-base-color: #6b16ed; - --theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, white); + --theme-base-color: oklch(49.65% 0.2709 289.33); + --theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, oklch(100% 0 0)); --theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground)); - --theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, #52525b); - --theme-placeholder-color: color-mix(in srgb, white 20%, var(--theme-base-color)); + --theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, oklch(44.19% 0.0146 285.79)); + /* Accent text/icons sit directly on dark surfaces. Force the lightness into a + readable band (and tame chroma) so any picked hue keeps enough contrast. */ + --theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.72, l, 0.86) min(c, 0.13) h); + /* Placeholders read as a light, near-neutral hint of the hue - never the raw + (possibly dark) base color, which is invisible on the dark input. */ + --theme-placeholder-color: oklch(from var(--theme-base-color) 0.62 min(c, 0.03) h); --color-accent: var(--theme-bright-color); --color-coollabs: var(--theme-bright-color); - --color-coollabs-100: color-mix(in oklab, var(--theme-bright-color) 88%, white); - --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, black); - --color-coollabs-300: color-mix(in oklab, var(--theme-bright-color) 72%, black); + --color-coollabs-100: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(100% 0 0)); + --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(0% 0 0)); + --color-coollabs-300: color-mix(in oklab, var(--theme-bright-color) 72%, oklch(0% 0 0)); /* Legacy dark-theme accent utilities use warning as the brand color. */ --color-warning: var(--theme-bright-color); --color-accent-foreground: var(--theme-accent-foreground); - --color-app: color-mix(in oklab, var(--theme-base-color) 12%, #09090a); - --color-panel: color-mix(in oklab, var(--theme-base-color) 14%, #0c0c0d); - --color-surface: color-mix(in oklab, var(--theme-base-color) 18%, #101011); - --color-raised: color-mix(in oklab, var(--theme-base-color) 24%, #141416); - --color-selected: color-mix(in oklab, var(--theme-base-color) 32%, #18181a); + --color-app: color-mix(in oklab, var(--theme-base-color) 12%, oklch(14.03% 0.0022 286.11)); + --color-panel: color-mix(in oklab, var(--theme-base-color) 14%, oklch(15.48% 0.0021 286.15)); + --color-surface: color-mix(in oklab, var(--theme-base-color) 18%, oklch(17.35% 0.0020 286.18)); + --color-raised: color-mix(in oklab, var(--theme-base-color) 24%, oklch(19.21% 0.0040 286.02)); + --color-selected: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06)); --color-coolgray-100: var(--color-surface); --color-coolgray-200: var(--color-raised); --color-coolgray-300: var(--color-selected); - --color-coolgray-400: color-mix(in oklab, var(--theme-base-color) 28%, #1b1b1e); - --color-coolgray-500: color-mix(in oklab, var(--theme-base-color) 32%, #202024); + --color-coolgray-400: color-mix(in oklab, var(--theme-base-color) 28%, oklch(22.34% 0.0058 285.92)); + --color-coolgray-500: color-mix(in oklab, var(--theme-base-color) 32%, oklch(24.52% 0.0075 285.83)); --coollabs-canvas: var(--color-app); --coollabs-elevated: var(--color-surface); --coollabs-recessed: var(--color-raised); - --coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, #111112); + --coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, oklch(17.81% 0.0020 286.19)); --color-content-surface: var(--coollabs-base); - --coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, #18181a); + --coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06)); --coollabs-line: var(--theme-border-color); --coollabs-hairline: color-mix(in srgb, var(--theme-border-color) 55%, var(--color-panel)); - --color-nav-text: #e4dfea; - --color-nav-muted: #d8d2df; - --color-nav-active: #ffffff; - --color-log: color-mix(in oklab, var(--theme-base-color) 20%, #080809); - --color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, #101011); - --color-fg-dim: #e4dfea; - --color-fg-faint: #d8d2df; - --coollabs-subtle: #d8d2df; + --color-nav-text: oklch(91.08% 0.0157 306.4); + --color-nav-muted: oklch(87.24% 0.0188 306.63); + --color-nav-active: oklch(100.0% 0 0); + --color-log: color-mix(in oklab, var(--theme-base-color) 20%, oklch(13.49% 0.0024 286.07)); + --color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, oklch(17.35% 0.0020 286.18)); + --color-fg-dim: oklch(91.08% 0.0157 306.4); + --color-fg-faint: oklch(87.24% 0.0188 306.63); + --coollabs-subtle: oklch(87.24% 0.0188 306.63); +} + +/* Light custom theme: the same single brand color, but surfaces tint toward + white and every foreground token flips to a dark, readable band. This selector + outranks the plain html:not(.dark) light palette, so it wins where they meet. + Accent fills (--theme-bright-color, --color-warning/coollabs) are inherited from + the block above so the JS-computed --theme-accent-foreground stays consistent. */ +html[data-theme="custom"]:not(.dark) { + --theme-scrollbar-thumb: color-mix(in srgb, var(--theme-base-color) 45%, oklch(60% 0 0)); + /* Accent text/icons on light surfaces: force lightness into a dark band. */ + --theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.30, l, 0.48) min(c, 0.16) h); + /* Placeholder: medium-dark, near-neutral hint of the hue. */ + --theme-placeholder-color: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + /* Surfaces hold a fixed high lightness (stays light, keeps dark text readable) + while injecting the base hue's chroma, so the tint is actually visible. + color-mix into white coupled lightness to the base and washed the tint out + (a 4% mix of any color into near-white is imperceptible). */ + --color-app: oklch(from var(--theme-base-color) 96.5% min(c, 0.035) h); + --color-panel: oklch(from var(--theme-base-color) 98.5% min(c, 0.025) h); + --color-surface: oklch(from var(--theme-base-color) 98% min(c, 0.03) h); + --color-raised: oklch(from var(--theme-base-color) 95.5% min(c, 0.04) h); + --color-selected: oklch(from var(--theme-base-color) 92% min(c, 0.05) h); + --color-coolgray-100: var(--color-surface); + --color-coolgray-200: var(--color-raised); + --color-coolgray-300: var(--color-selected); + --color-coolgray-400: oklch(from var(--theme-base-color) 90% min(c, 0.05) h); + --color-coolgray-500: oklch(from var(--theme-base-color) 87% min(c, 0.055) h); + --coollabs-canvas: var(--color-app); + --coollabs-elevated: oklch(from var(--theme-base-color) 98.5% min(c, 0.025) h); + --coollabs-recessed: var(--color-raised); + --coollabs-base: oklch(from var(--theme-base-color) 99.3% min(c, 0.018) h); + --color-content-surface: var(--coollabs-base); + --coollabs-fill: oklch(from var(--theme-base-color) 91% min(c, 0.05) h); + --coollabs-line: oklch(from var(--theme-base-color) 82% min(c, 0.055) h); + --coollabs-hairline: oklch(from var(--theme-base-color) 88% min(c, 0.04) h); + --color-nav-text: oklch(from var(--theme-base-color) 0.40 min(c, 0.04) h); + --color-nav-muted: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + --color-nav-active: oklch(from var(--theme-base-color) 0.22 min(c, 0.05) h); + --color-log: oklch(from var(--theme-base-color) 96.5% min(c, 0.035) h); + --color-log-toolbar: oklch(from var(--theme-base-color) 93% min(c, 0.045) h); + --color-fg: oklch(from var(--theme-base-color) 0.22 min(c, 0.04) h); + --color-fg-dim: oklch(from var(--theme-base-color) 0.40 min(c, 0.04) h); + --color-fg-faint: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + --coollabs-subtle: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); } html[data-theme="custom"] .control-selected, @@ -1051,7 +1376,55 @@ html[data-theme="custom"] [class~="dark:bg-white/[0.025]"] { html[data-theme="custom"] input::placeholder, html[data-theme="custom"] textarea::placeholder { color: var(--theme-placeholder-color) !important; - opacity: 0.7; + /* The token is already a readable light tone; no extra opacity dampening, + which would drop it back below the contrast floor. */ + opacity: 1; +} + +/* Accent text/icons (links, badges, "update available" pill, check marks) use the + brand color as foreground on dark surfaces. Route them to the clamped, always + readable token; fills (bg-*) keep the brighter accent + its computed foreground. */ +html[data-theme="custom"] :is( + [class~="text-coollabs"], + [class~="text-warning"], + [class~="text-accent"], + [class~="dark:text-coollabs"], + [class~="dark:text-warning"] +) { + color: var(--theme-fg-on-surface) !important; +} + +html[data-theme="custom"] [class~="hover:text-coollabs"]:hover, +html[data-theme="custom"] [class~="hover:text-warning"]:hover, +html[data-theme="custom"] [class~="dark:hover:text-warning"]:hover, +html[data-theme="custom"] [class~="dark:hover:text-coollabs"]:hover { + color: var(--theme-fg-on-surface) !important; +} + +/* Light custom theme reaches raw neutral utilities too. Dark mode tints + everything through the pervasive dark:bg-*/dark:border-* utilities (which + resolve to --color-* vars the block overrides); light mode has no equivalent, + so components fall back to hardcoded neutrals. Remap the dominant light + surface/border utilities to the tinted custom vars so the color reaches the + whole UI, not just design-system classes. */ +html[data-theme="custom"]:not(.dark) :is([class~="bg-white"], [class~="bg-white/95"], [class~="bg-white/80"]) { + background-color: var(--coollabs-elevated) !important; +} + +html[data-theme="custom"]:not(.dark) [class~="bg-neutral-50"] { + background-color: var(--coollabs-canvas) !important; +} + +html[data-theme="custom"]:not(.dark) :is([class~="bg-neutral-100"], [class~="bg-neutral-200"]) { + background-color: var(--coollabs-fill) !important; +} + +html[data-theme="custom"]:not(.dark) [class~="border-neutral-200"] { + border-color: var(--coollabs-hairline) !important; +} + +html[data-theme="custom"]:not(.dark) :is([class~="border-neutral-300"], [class~="border-neutral-100"]) { + border-color: var(--coollabs-line) !important; } html[data-theme="custom"] input:read-only, @@ -1318,27 +1691,6 @@ html[data-theme="custom"] textarea:disabled { margin-top: 1.75rem; } -.error-contact-link { - display: inline-flex; - align-items: center; - gap: 0.25rem; - margin-left: 0.25rem; - font-size: 0.8125rem; - font-weight: 500; - color: #525252; - text-underline-offset: 3px; - transition: color 0.15s ease; -} - -.dark .error-contact-link { - color: var(--color-fg-dim); -} - -.error-contact-link:hover { - color: var(--color-accent); - text-decoration: underline; -} - /* Layer card root: elevated shell with a hairline ring. overflow stays visible so floating panels (listbox, tooltips) can escape the card. */ .application-settings-section { @@ -1349,7 +1701,9 @@ html[data-theme="custom"] textarea:disabled { border-radius: 8px; border: none; background: var(--coollabs-elevated); - box-shadow: 0 0 0 1px var(--coollabs-hairline); + /* crisp ring + a restrained lift so the card separates from the canvas + (shadcn-style), not a heavy floating shadow */ + box-shadow: 0 0 0 1px var(--coollabs-hairline), 0 1px 2px 0 rgb(0 0 0 / 0.05); /* keep anchored scrolls (scrollIntoView) clear of the fixed topbar + layer-2 nav */ scroll-margin-top: 7rem; } @@ -1415,7 +1769,14 @@ html[data-theme="custom"] textarea:disabled { font-weight: 500; line-height: 1.25rem; letter-spacing: 0; - color: inherit; + /* Card title is a heading, so give it a readable strength instead of the + muted --coollabs-subtle (5.66:1 light / 6.98:1 dark). ~8.7:1 in both + modes (comfortable AAA) while descriptions/labels stay muted. */ + color: oklch(40% 0 0); +} + +.dark .application-settings-section > :is(header, .application-settings-section-header) :is(h2, h3) { + color: var(--color-fg-dim); } .application-settings-section > .application-settings-section-header p { @@ -1425,12 +1786,16 @@ html[data-theme="custom"] textarea:disabled { color: var(--coollabs-subtle); } -/* Layer card body: nested rounded panel with base bg + fill ring */ +/* Layer card body: nested rounded panel with base bg. Ring uses the same + --coollabs-hairline as the card shell (line ~1456) so the body edge and the + header edge read as one continuous border instead of two different grays + (fill was lighter than hairline, so the header sides and body sides did not + match). */ .application-settings-section-body { position: relative; border-radius: 8px; background: var(--coollabs-base); - box-shadow: 0 0 0 1px var(--coollabs-fill); + box-shadow: 0 0 0 1px var(--coollabs-hairline); padding: 1rem; } @@ -1469,28 +1834,71 @@ html[data-theme="custom"] textarea:disabled { } @media (min-width: 1280px) { + /* Fixed like the main sidebar (3rem → bottom); left tracks its width (--sidebar-w). + Fixed leaves the grid, so the content sibling is pinned to column 2 below. */ .application-settings-navigation { - position: sticky; - top: calc(3rem + 1.75rem); - align-self: start; - max-height: calc(100dvh - 5.5rem); - padding-right: 0.375rem; + position: fixed; + top: 3rem; + left: var(--sidebar-w, 14rem); + width: calc(210px + 2.5rem); + height: calc(100dvh - 3rem); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; + scrollbar-color: var(--coollabs-fill) transparent; + scrollbar-gutter: stable; + --rail-fill: var(--coollabs-elevated); + padding: 0.5rem; + background: var(--rail-fill); + border-right: 1px solid var(--coollabs-hairline); + transition: left 200ms ease; } - /* Resource pages have a second fixed header row below the main header. */ - .application-settings-workspace > .application-settings-navigation { - top: 4rem; - max-height: calc(100dvh - 5rem); + .application-settings-navigation ~ * { + grid-column: 2; } + html:not(.dark) .application-settings-navigation { + --rail-fill: var(--coollabs-base); + } + + .application-settings-navigation::-webkit-scrollbar { + width: 6px; + } + + .application-settings-navigation::-webkit-scrollbar-track { + background: transparent; + } + + .application-settings-navigation::-webkit-scrollbar-thumb { + border-radius: 9999px; + background: var(--coollabs-fill); + } + + .application-settings-navigation::-webkit-scrollbar-thumb:hover { + background: var(--coollabs-line); + } + + /* Embeds: keep the surface, drop the fixed positioning. */ .application-settings-navigation.is-flush { position: static; - max-height: none; + inset: auto; + width: auto; + height: auto; overflow: visible; + transition: none; + } +} + +/* Honor reduced-motion for the settings nav disclosure/accordion and the mobile + sidebar sheet: keep open/close functional but drop the height/slide/fade. */ +@media (prefers-reduced-motion: reduce) { + .application-settings-navigation, + .application-settings-navigation *, + .mobile-sidebar-sheet * { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; } } @@ -1555,12 +1963,17 @@ html[data-theme="custom"] textarea:disabled { padding-right: 2.5rem; } +.application-settings-workspace .input.input-with-copy-and-peek, +.application-settings-form .input.input-with-copy-and-peek { + padding-right: 4.25rem; +} + .application-settings-workspace .input:focus-visible, .application-settings-workspace .select:focus-visible, .application-settings-form .input:focus-visible, .application-settings-form .select:focus-visible { border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent) !important; + box-shadow: none !important; } .application-settings-workspace textarea.input, @@ -1603,7 +2016,7 @@ html[data-theme="custom"] textarea:disabled { * Circular chevrons sit over gradient fades when more tabs are off-screen. */ .resource-heading-tabs-scroller { - --resource-heading-tabs-fade: #f5f5f5; /* neutral-100, matches pill track */ + --resource-heading-tabs-fade: oklch(97.02% 0 0); /* neutral-100, matches pill track */ } .dark .resource-heading-tabs-scroller { @@ -1662,7 +2075,7 @@ html[data-theme="custom"] textarea:disabled { margin-inline: 0.25rem; border-radius: 9999px; color: #737373; - background: #ffffff; + background: var(--coollabs-base); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.08); @@ -1736,20 +2149,6 @@ html[data-theme="custom"] textarea:disabled { box-shadow: none !important; } -.resource-heading-overflow-separator { - width: 1px; - align-self: stretch; - margin: 0.25rem 0.25rem; - background: color-mix(in srgb, var(--coollabs-line) 80%, transparent); -} - -.resource-heading-overflow.is-collapsed .resource-heading-overflow-separator { - width: auto; - height: 1px; - align-self: auto; - margin: 0.25rem 0.375rem; -} - .resource-heading-overflow.is-collapsed .resource-heading-overflow-items > .button, .resource-heading-overflow.is-collapsed .resource-heading-overflow-items > a.button { width: 100%; @@ -1974,7 +2373,7 @@ html[data-theme="custom"] textarea:disabled { .listbox-trigger:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); + box-shadow: none; } .listbox-panel { @@ -1999,7 +2398,7 @@ html[data-theme="custom"] textarea:disabled { border-radius: 10px; border: 1px solid var(--coollabs-line); background: var(--coollabs-recessed); - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-dropdown); } @media (max-width: 767px) { @@ -2095,7 +2494,7 @@ html[data-theme="custom"] textarea:disabled { .searchable-listbox-search-input:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); + box-shadow: none; } /* Hide native WebKit search clear so custom clear buttons are not doubled */ @@ -2207,6 +2606,14 @@ input[type="search"]::-webkit-search-results-decoration { } /* Data table (layer-card body, full-bleed) */ +.data-table { + min-width: 0; + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + .data-table-header { display: grid; align-items: center; @@ -2568,21 +2975,38 @@ input[type="search"]::-webkit-search-results-decoration { } .service-backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr); - min-width: 45rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 12.5rem; + width: 100%; } -/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */ +.data-table-row.service-backup-table-grid { + background: var(--coollabs-base); + border-bottom: 1px solid var(--coollabs-fill); +} + +.data-table-row.service-backup-table-grid:last-child { + border-bottom: 0; +} + +.data-table-row.service-backup-table-grid:hover { + background: color-mix(in srgb, var(--coollabs-base) 98%, black); +} + +.dark .data-table-row.service-backup-table-grid:hover { + background: color-mix(in srgb, var(--coollabs-base) 98%, white); +} + +/* Persistent storage volumes: Name | Destination | [PR suffix] | Backup | [Actions] */ .volumes-table-grid-readonly { - grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 5rem; + grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem; } .volumes-table-grid { - grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) 5rem 12rem; + grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 15rem; } .volumes-table-grid-with-pr { - grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem 5rem 12rem; + grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem; } .volumes-mobile-label { @@ -2616,35 +3040,6 @@ input[type="search"]::-webkit-search-results-decoration { padding-inline: 0.5rem; } -@media (max-width: 1100px) { - .volumes-table-grid { - grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 12rem; - } - - .volumes-table-grid > .volumes-col-source, - .data-table-header.volumes-table-grid > .volumes-col-source { - display: none; - } - - .volumes-table-grid-with-pr { - grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 12rem; - } - - .volumes-table-grid-with-pr > .volumes-col-source, - .data-table-header.volumes-table-grid-with-pr > .volumes-col-source { - display: none; - } - - .volumes-table-grid-readonly { - grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem; - } - - .volumes-table-grid-readonly > .volumes-col-source, - .data-table-header.volumes-table-grid-readonly > .volumes-col-source { - display: none; - } -} - /* Phone: stacked card rows with per-field labels (table headers hidden) */ @media (max-width: 768px) { .data-table-header.volumes-table-grid, @@ -2664,10 +3059,7 @@ input[type="search"]::-webkit-search-results-decoration { min-height: 0; } - .data-table-row.volumes-table-grid > .volumes-col-source, - .data-table-row.volumes-table-grid-with-pr > .volumes-col-source, - .data-table-row.volumes-table-grid-with-pr > .volumes-col-pr, - .data-table-row.volumes-table-grid-readonly > .volumes-col-source { + .data-table-row.volumes-table-grid-with-pr > .volumes-col-pr { display: flex; flex-direction: column; gap: 0.25rem; @@ -2703,7 +3095,10 @@ input[type="search"]::-webkit-search-results-decoration { } .volumes-col-backup { - align-items: flex-start; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; } .volumes-cell-actions { @@ -2766,18 +3161,6 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem; } -.scheduled-executions-table-grid { - grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr); -} - -.scheduler-runs-table-grid { - grid-template-columns: 9rem minmax(12rem, 1.5fr) 6rem 6rem 6rem; -} - -.skipped-jobs-table-grid { - grid-template-columns: 9rem 7rem minmax(10rem, 1fr) minmax(12rem, 1.4fr); -} - .server-resources-managed-table-grid { grid-template-columns: minmax(10rem, 1.3fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 8rem 9.5rem; } @@ -2809,7 +3192,7 @@ input[type="search"]::-webkit-search-results-decoration { } .volume-backup-executions-grid { - grid-template-columns: 7.5rem minmax(0, 1fr) 8.5rem 7rem 9rem minmax(9rem, 0.7fr); + grid-template-columns: 7.5rem minmax(14rem, 1fr) 8.5rem 7rem 9rem minmax(9rem, 0.7fr); min-width: 50rem; } @@ -2847,15 +3230,6 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } - .scheduled-executions-table-grid { - grid-template-columns: 5.5rem minmax(8rem, 1fr) 8.5rem minmax(10rem, 1.3fr); - } - - .scheduled-executions-table-grid > :nth-child(3), - .scheduled-executions-table-grid > :nth-child(5) { - display: none; - } - .server-resources-managed-table-grid { grid-template-columns: minmax(9rem, 1fr) minmax(8rem, 0.8fr) 8rem 9.5rem; } @@ -2909,32 +3283,6 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } - .scheduled-executions-table-grid { - grid-template-columns: 5.5rem minmax(0, 1fr) 8.5rem; - } - - .scheduled-executions-table-grid > :nth-child(6) { - display: none; - } - - .scheduler-runs-table-grid { - grid-template-columns: 8.5rem minmax(0, 1fr) 5.5rem; - } - - .scheduler-runs-table-grid > :nth-child(3), - .scheduler-runs-table-grid > :nth-child(4) { - display: none; - } - - .skipped-jobs-table-grid { - grid-template-columns: 8.5rem minmax(0, 1fr); - } - - .skipped-jobs-table-grid > :nth-child(2), - .skipped-jobs-table-grid > :nth-child(4) { - display: none; - } - /* Name + Status only — fixed Type/Status tracks were crushing Name into "NameType". */ .server-resources-managed-table-grid { grid-template-columns: minmax(0, 1fr) auto; @@ -3036,7 +3384,7 @@ input[type="search"]::-webkit-search-results-decoration { /* Shared logs viewer (deployment + runtime) — mobile-first toolbar Mobile stacks: search → meta → full-width actions (no side-by-side overlap). */ .logs-viewer { - background: #fff; + background: var(--coollabs-base); color: #262626; } @@ -3537,7 +3885,7 @@ html[data-theme="custom"] .logs-viewer-timestamp { .runtime-log-viewport { min-height: 12rem; border-radius: 0 0 8px 8px; - background: #fafafa; + background: var(--coollabs-elevated); color: #262626; } @@ -3552,6 +3900,228 @@ html[data-theme="custom"] .logs-viewer-timestamp { color: var(--color-fg-dim); } +.runtime-log-panel { + --runtime-log-line: rgba(0, 0, 0, 0.08); + --runtime-log-muted: #66666f; + --runtime-log-hover: rgba(0, 0, 0, 0.04); + --runtime-log-detail: rgba(0, 0, 0, 0.03); + --runtime-log-columns: 12.75rem 5.5rem minmax(0, 1fr); +} + +.dark .runtime-log-panel { + --runtime-log-line: var(--glass-line, rgba(255, 255, 255, 0.065)); + --runtime-log-muted: #a09da5; + --runtime-log-hover: rgba(255, 255, 255, 0.035); + --runtime-log-detail: rgba(0, 0, 0, 0.24); +} + +.runtime-log-viewport.logs-viewer-viewport { + container: runtime-log-explorer / inline-size; + padding: 0; +} + +.runtime-log-viewport.logs-viewer-viewport::after { + display: none; +} + +.runtime-log-columns, +.runtime-log-viewport [data-log-line]:not(.hidden) { + display: grid; + grid-template-columns: var(--runtime-log-columns); + gap: 0.625rem; + box-sizing: border-box; + width: 100%; + min-height: 2.75rem; + align-items: center; + padding: 0.6875rem 1.875rem 0.6875rem 1rem; +} + +.runtime-log-columns { + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid var(--runtime-log-line); + background: #f0eff2; + color: var(--runtime-log-muted); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; +} + +.dark .runtime-log-columns { + background: var(--coollabs-elevated); +} + +html[data-theme="custom"] .runtime-log-columns { + background: var(--color-log-toolbar); +} + +.runtime-log-viewport [data-log-line] { + position: relative; + border-bottom: 1px solid var(--runtime-log-line); + border-radius: 0; + cursor: pointer; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.6; +} + +.runtime-log-viewport [data-log-line]:hover, +.runtime-log-viewport [data-log-line][aria-expanded="true"] { + background: var(--runtime-log-hover); +} + +.runtime-log-viewport [data-log-line]:focus-visible { + outline: 2px solid var(--color-accent, #b93642); + outline-offset: -2px; +} + +.runtime-log-viewport [data-log-line]::before { + content: "[" attr(data-log-level) "]"; + grid-column: 2; + grid-row: 1; + color: var(--runtime-log-muted); + text-transform: uppercase; +} + +.runtime-log-viewport [data-log-line]::after { + content: ""; + position: absolute; + top: 1rem; + right: 0.75rem; + width: 0.375rem; + height: 0.375rem; + border-right: 1.5px solid var(--runtime-log-muted); + border-bottom: 1.5px solid var(--runtime-log-muted); + transform: rotate(45deg); +} + +.runtime-log-viewport [data-log-line][aria-expanded="true"]::after { + top: 1.1875rem; + transform: rotate(225deg); +} + +.runtime-log-viewport .log-error::before { color: #e55e73; } +.runtime-log-viewport .log-warning::before { color: #d79945; } +.runtime-log-viewport .log-debug::before { color: #929099; } +.runtime-log-viewport .log-info::before { color: #8891f0; } + +.runtime-log-viewport .logs-viewer-timestamp { + grid-column: 1; + grid-row: 1; + color: var(--runtime-log-muted); + font-size: 0.8125rem; + line-height: 1.6; + white-space: nowrap; +} + +.runtime-log-viewport [data-line-text] { + grid-column: 3; + grid-row: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-log-detail { + margin: 0 0 0.25rem; + padding: 1.25rem; + border-bottom: 1px solid var(--runtime-log-line); + background: var(--runtime-log-detail); + color: inherit; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.8; +} + +.dark .runtime-log-detail { + color: #adbdc9; +} + +.runtime-log-viewport [data-log-line].hidden + .runtime-log-detail { + display: none !important; +} + +.runtime-log-without-time { + --runtime-log-columns: 5.5rem minmax(0, 1fr); +} + +.runtime-log-without-time [data-log-line]::before { + grid-column: 1; +} + +.runtime-log-without-time [data-line-text] { + grid-column: 2; +} + +@container runtime-log-explorer (max-width: 650px) { + .runtime-log-columns, + .runtime-log-viewport [data-log-line]:not(.hidden) { + grid-template-columns: minmax(0, 1fr) 5.5rem; + gap: 0.1875rem 0.5rem; + } + + .runtime-log-columns > :last-child, + .runtime-log-viewport [data-line-text] { + grid-column: 1 / -1; + grid-row: 2; + } + + .runtime-log-without-time [data-line-text] { + grid-column: 1 / -1; + } +} + +.runtime-log-empty { + display: flex; + min-height: 18rem; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + color: var(--runtime-log-muted); + text-align: left; +} + +.runtime-log-loading { + min-height: 18rem; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 2rem; + color: var(--runtime-log-muted); + font-size: 0.8125rem; + font-weight: 500; +} + +.runtime-log-empty-icon { + display: inline-flex; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 1px solid var(--runtime-log-line); + border-radius: 0.5rem; + background: var(--runtime-log-detail); +} + +.runtime-log-empty p { + color: inherit; + font-size: 0.8125rem; + font-weight: 500; +} + +.runtime-log-empty div > span { + display: block; + margin-top: 0.125rem; + font-size: 0.75rem; + line-height: 1.25rem; +} + .env-table-detail { padding: 0.25rem 1rem 1.25rem; } @@ -3633,7 +4203,7 @@ html[data-theme="custom"] .logs-viewer-timestamp { .chip-input:focus-within { border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); + box-shadow: none; } .chip-input input { @@ -3706,93 +4276,6 @@ html[data-theme="custom"] .logs-viewer-timestamp { color: inherit; } -/* Detached pill options (segmented radio groups rendered as separate pills) */ -.option-pill-group { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.option-pill-group label { - margin-bottom: 0; -} - -.option-pill { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.375rem; - height: 2rem; - padding: 0 0.875rem; - border-radius: 8px; - border: 1px solid var(--coollabs-line); - background: var(--coollabs-elevated); - font-size: 0.875rem; - font-weight: 500; - white-space: nowrap; - color: #000000; - transition: background-color 0.15s, border-color 0.15s; -} - -.dark .option-pill { - color: var(--color-fg); -} - -.option-pill:hover { - background: var(--coollabs-recessed); -} - -.peer:checked + .option-pill { - background: var(--coollabs-fill); - border-color: var(--coollabs-line); -} - -.peer:disabled + .option-pill { - cursor: not-allowed; - opacity: 0.5; -} - -/* Label column + content rows inside a card body */ -.application-purpose-row { - display: grid; - gap: 1rem; - border-top: 1px solid var(--coollabs-fill); - padding-top: 1.25rem; -} - -@media (min-width: 768px) { - .application-purpose-row { - grid-template-columns: 13rem minmax(0, 1fr); - gap: 2rem; - } -} - -.application-purpose-copy h4 { - font-size: 0.875rem; - font-weight: 600; - color: #000000; -} - -.dark .application-purpose-copy h4 { - color: var(--color-fg); -} - -.application-purpose-copy p { - margin-top: 0.25rem; - font-size: 0.875rem; - line-height: 1.25rem; - color: #71717a; -} - -.dark .application-purpose-copy p { - color: var(--color-fg-dim); -} - -.application-details-card .application-purpose-row { - grid-template-columns: minmax(0, 1fr); - gap: 1rem; -} - /* In-card sub-headings (e.g. "Container labels", "Docker Compose") */ .application-settings-form h4, .application-settings-form .application-settings-section-body h3 { @@ -3820,11 +4303,11 @@ html[data-theme="custom"] .logs-viewer-timestamp { .projects-table-grid { display: grid; grid-template-columns: - minmax(220px, 1.7fr) - minmax(100px, 0.65fr) - minmax(90px, 0.6fr) - minmax(220px, 1.5fr) - 6rem; + minmax(200px, 1.7fr) + 8rem + 7rem + minmax(200px, 1.6fr) + 5rem; column-gap: 1rem; } @@ -3871,7 +4354,7 @@ html[data-theme="custom"] .logs-viewer-timestamp { @media (max-width: 1050px) { .projects-table-grid { - grid-template-columns: minmax(220px, 1fr) 7rem 6rem 6rem; + grid-template-columns: minmax(200px, 1fr) 8rem 7rem 5rem; } .projects-table-grid .project-description { @@ -3949,7 +4432,7 @@ html[data-theme="custom"] .logs-viewer-timestamp { overflow: hidden; border-radius: 10px; background: var(--coollabs-elevated); - box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal); + box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-dropdown); } .command-palette-header { @@ -4423,3 +4906,127 @@ a.command-palette-item:focus-visible { .dark .command-palette-arch-badge { color: #fcd34d; } + +/* Service domains prioritize public addresses; configuration lives in settings. */ +#service-domains-section, +.domains-overview-container { + container: service-domains / inline-size; +} + +.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) 7.25rem 7.5rem 5.5rem 6.5rem 8rem 6.5rem; + column-gap: 0.75rem; +} + +.data-table-row.service-domains-overview-grid { + padding-block: 0.5rem; +} + +.service-domain-detail { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + font-size: 12px; +} + +.service-domains-overview-grid > span:not(:first-child):not(:last-child) { + text-align: center; +} + +.service-domain-detail-label { + display: none; +} + +.service-domain-mobile-summary { + display: none; +} + +.service-domains-https .listbox-trigger { + min-width: 7rem; +} + +@container service-domains (max-width: 980px) { + .data-table-header.service-domains-overview-grid { + display: none; + } + + .data-table-row.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-areas: "domain dns actions" "summary summary summary"; + gap: 0.625rem 0.75rem; + padding: 0.75rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-area: domain; + } + + .service-domain-detail, + .domains-service-desktop { + display: none; + } + + .service-domain-mobile-summary { + display: flex; + grid-area: summary; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem 0.75rem; + color: var(--coollabs-fg-dim); + font-size: 12px; + line-height: 1.25rem; + } + + .service-domain-mobile-summary > span:not(:last-child)::after { + margin-left: 0.75rem; + color: var(--coollabs-line); + content: "·"; + } + + .service-domain-dns { + grid-area: dns; + justify-self: end; + } + + .service-domain-actions { + grid-area: actions; + justify-self: end; + } +} + +@container service-domains (max-width: 600px) { + .data-table-row.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "domain domain" "summary summary" "dns actions"; + gap: 0.625rem 0.75rem; + padding: 0.875rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-area: domain; + } + + .data-table-row.service-domains-overview-grid > :first-child a, + .data-table-row.service-domains-overview-grid > :first-child span[title] { + overflow: visible; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; + } + + .service-domain-dns { + grid-area: dns; + justify-self: start; + } + + .service-domain-actions { + grid-area: actions; + justify-self: end; + } + + .service-domain-actions .icon-button { + width: 2.5rem; + height: 2.5rem; + } +} diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 6fdd260b5f..96511bbf79 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -15,7 +15,7 @@ } @utility apexcharts-tooltip-custom { - @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm; + @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-dropdown p-3 text-sm; min-width: 160px; } @@ -59,7 +59,7 @@ /* input, select before */ @utility input-select { - @apply block h-9 px-3 py-1.5 w-full text-sm text-black rounded-md border border-neutral-200 bg-white dark:bg-surface dark:text-fg dark:border-white/[0.08] transition-colors disabled:bg-neutral-100 disabled:text-neutral-400 dark:disabled:bg-white/[0.03] dark:disabled:text-fg-faint; + @apply block h-8 px-3 py-1.5 w-full text-sm text-black rounded-md border border-neutral-200 bg-[var(--coollabs-recessed)] dark:text-fg dark:border-white/[0.08] transition-colors disabled:bg-neutral-100 disabled:text-neutral-400 dark:disabled:bg-white/[0.03] dark:disabled:text-fg-faint; box-shadow: none; &:where(.dark, .dark *) { @@ -77,18 +77,18 @@ /* Readonly */ @utility input { - @apply dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200; + @apply dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 read-only:text-neutral-500 read-only:bg-neutral-200; @apply input-select; @apply focus-visible:outline-none; &:focus-visible { border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); + box-shadow: none; } &:where(.dark, .dark *):focus-visible { border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); + box-shadow: none; } &:read-only { @@ -116,17 +116,17 @@ &:focus-visible { border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); + box-shadow: none; } &:where(.dark, .dark *):focus-visible { border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); + box-shadow: none; } } @utility button { - @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; + @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black capitalize rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; } @utility button-highlighted { @@ -137,6 +137,10 @@ @apply bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white; } +@utility view-toggle { + @apply flex h-8 items-center rounded-lg border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-white/[0.06]; +} + @utility loading-indicator { @apply text-coollabs dark:text-warning; } @@ -162,7 +166,7 @@ } @utility auth-tooltip { - @apply fixed z-[10000] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10; + @apply fixed z-[10000] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-dropdown dark:text-fg dark:bg-raised dark:border-white/10; } @utility alert-success { @@ -222,7 +226,10 @@ } @utility menu-item { - @apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05]; + /* Translucent hover (like the active pill) so it darkens against any surface, + including the light-mode settings rail's gray fill; on white it matches the + old neutral-100. */ + @apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-nav-text hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.05]; } @utility menu-item-icon { @apply shrink-0 size-[18px] opacity-90; @@ -242,10 +249,15 @@ @apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none; } +/* Collapsible group header (accordion) for the resource settings sidebar. */ +@utility nav-section-toggle { + @apply w-full items-center justify-between gap-2 px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none rounded-md transition-colors cursor-pointer hover:text-nav-active; +} + /* Indented child rows in a collapsible nav group */ @utility menu-subitem { /* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */ - @apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05]; + @apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-nav-text hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.05]; } @utility menu-subitem-active { @apply rounded-md bg-black/[0.05] text-nav-active hover:bg-black/[0.05] dark:bg-white/[0.06] dark:hover:bg-white/[0.06]; @@ -344,7 +356,7 @@ } @utility info-helper-popup { - @apply rounded-lg border border-neutral-200 bg-white text-neutral-600 shadow-modal whitespace-normal break-words dark:border-white/10 dark:bg-raised dark:text-fg-dim; + @apply rounded-lg border border-neutral-200 bg-white text-neutral-600 shadow-dropdown whitespace-normal break-words dark:border-white/10 dark:bg-raised dark:text-fg-dim; } @utility buyme { @@ -367,6 +379,14 @@ @apply z-[1]; } +/* Floating notice / popover surface: elevated fill, hairline ring, and the + shared dropdown lift. Toasts, change-pending popovers and the deployments + list share this so they match menus/dropdowns instead of the heavier modal. */ +@utility surface-popover { + background: var(--coollabs-elevated); + box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-dropdown); +} + @utility dz-button { @apply p-4 py-10 my-4 w-full font-bold bg-white border dark:border-coolgray-400 dark:text-white dark:bg-transparent dark:hover:bg-coolgray-400; } @@ -404,15 +424,15 @@ } @media (min-width: 1024px) { - .sidebar-collapsed .menu-item { - justify-content: center; - width: var(--button-h, 2rem); - height: var(--button-h, 2rem); - min-height: var(--button-h, 2rem); - padding-left: 0; - padding-right: 0; - gap: 0; - margin-inline: auto; + /* Collapsed rail keeps every nav icon at its expanded x-position (left-aligned, + same 10px inset) so muscle memory holds when the sidebar is toggled; only the + label is hidden. Unlayered rule outranks the inline lg:justify-center/lg:px-0 + utilities on each row. The footer collapse toggle keeps its own centered + square via the .sidebar-toggle exclusion. */ + .sidebar-collapsed .menu-item:not(.sidebar-toggle) { + justify-content: flex-start; + padding-left: 0.625rem; + padding-right: 0.625rem; } .sidebar-collapsed .sidebar-collapsed-label { diff --git a/resources/js/app.js b/resources/js/app.js index 9315daa449..b912d827e6 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,7 @@ import { initializeCopyButtonComponent } from './copy-button.js'; +import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js'; import { initializeTerminalComponent } from './terminal.js'; +import './traffic-globe.js'; import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js'; document.addEventListener('livewire:init', () => { @@ -19,6 +21,7 @@ document.addEventListener('livewire:navigated', () => { // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); document.addEventListener('alpine:init', initializeCopyButtonComponent); +document.addEventListener('alpine:init', initializeSettingsSidebarAccordionComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms @@ -126,3 +129,16 @@ window.scrollToSettingsSection = function scrollToSettingsSection(id) { rafId = window.requestAnimationFrame(tick); }; + +// When a settings sub-section link navigates across pages (href="route#section-id"), +// scroll to that section once the destination page has rendered. +function scrollToHashSettingsSection() { + const hash = window.location.hash; + if (!hash || hash.length < 2) { + return; + } + const id = decodeURIComponent(hash.slice(1)); + window.requestAnimationFrame(() => window.scrollToSettingsSection?.(id)); +} +document.addEventListener('livewire:navigated', scrollToHashSettingsSection); +document.addEventListener('DOMContentLoaded', scrollToHashSettingsSection); diff --git a/resources/js/settings-sidebar-accordion.js b/resources/js/settings-sidebar-accordion.js new file mode 100644 index 0000000000..fc62865f0b --- /dev/null +++ b/resources/js/settings-sidebar-accordion.js @@ -0,0 +1,57 @@ +// Alpine data provider for the collapsible resource settings sidebar +// (x-data="settingsSidebarAccordion({ activeGroup, storageKey })"). +// +// Only the group that contains the current page is open by default; every group +// can be collapsed/expanded and the choice is remembered per resource type. The +// active group is always forced open on load so the current page stays reachable. +export function initializeSettingsSidebarAccordionComponent() { + window.Alpine.data('settingsSidebarAccordion', (config = {}) => ({ + activeGroup: config.activeGroup || '', + storageKey: config.storageKey || 'coolify.settings-sidebar', + groups: {}, + // Optional client-side filter (sidebars that render a search box). + search: '', + labels: Array.isArray(config.labels) ? config.labels : [], + get searching() { + return this.search.trim() !== ''; + }, + matches(label) { + if (!this.searching) { + return true; + } + return String(label).toLowerCase().includes(this.search.trim().toLowerCase()); + }, + get hasResults() { + return !this.searching || this.labels.some((label) => this.matches(label)); + }, + init() { + let stored = {}; + try { + stored = JSON.parse(localStorage.getItem(this.storageKey)) || {}; + } catch (e) { + stored = {}; + } + this.groups = stored && typeof stored === 'object' ? stored : {}; + }, + isOpen(group) { + // The current page must stay visible, even when this group was + // previously stored as collapsed on another page. + if (group === this.activeGroup) { + return true; + } + + if (Object.prototype.hasOwnProperty.call(this.groups, group)) { + return this.groups[group]; + } + return false; + }, + toggle(group) { + this.groups = { ...this.groups, [group]: !this.isOpen(group) }; + try { + localStorage.setItem(this.storageKey, JSON.stringify(this.groups)); + } catch (e) { + // ignore storage errors (private mode, quota, etc.) + } + }, + })); +} diff --git a/resources/js/traffic-globe.js b/resources/js/traffic-globe.js new file mode 100644 index 0000000000..49f52ac9ac --- /dev/null +++ b/resources/js/traffic-globe.js @@ -0,0 +1,239 @@ +import createGlobe from 'cobe'; + +// ISO-3166 alpha-2 -> [lat, lng] centroids (Google public-data canonical set). +// Used to place request-volume markers on the interactive globe. Kept inline so +// the globe has zero runtime fetch dependency. +const CENTROIDS = {"AD":[42.546245,1.601554],"AE":[23.424076,53.847818],"AF":[33.93911,67.709953],"AG":[17.060816,-61.796428],"AI":[18.220554,-63.068615],"AL":[41.153332,20.168331],"AM":[40.069099,45.038189],"AN":[12.226079,-69.060087],"AO":[-11.202692,17.873887],"AQ":[-75.250973,-0.071389],"AR":[-38.416097,-63.616672],"AS":[-14.270972,-170.132217],"AT":[47.516231,14.550072],"AU":[-25.274398,133.775136],"AW":[12.52111,-69.968338],"AZ":[40.143105,47.576927],"BA":[43.915886,17.679076],"BB":[13.193887,-59.543198],"BD":[23.684994,90.356331],"BE":[50.503887,4.469936],"BF":[12.238333,-1.561593],"BG":[42.733883,25.48583],"BH":[25.930414,50.637772],"BI":[-3.373056,29.918886],"BJ":[9.30769,2.315834],"BM":[32.321384,-64.75737],"BN":[4.535277,114.727669],"BO":[-16.290154,-63.588653],"BR":[-14.235004,-51.92528],"BS":[25.03428,-77.39628],"BT":[27.514162,90.433601],"BV":[-54.423199,3.413194],"BW":[-22.328474,24.684866],"BY":[53.709807,27.953389],"BZ":[17.189877,-88.49765],"CA":[56.130366,-106.346771],"CC":[-12.164165,96.870956],"CD":[-4.038333,21.758664],"CF":[6.611111,20.939444],"CG":[-0.228021,15.827659],"CH":[46.818188,8.227512],"CI":[7.539989,-5.54708],"CK":[-21.236736,-159.777671],"CL":[-35.675147,-71.542969],"CM":[7.369722,12.354722],"CN":[35.86166,104.195397],"CO":[4.570868,-74.297333],"CR":[9.748917,-83.753428],"CU":[21.521757,-77.781167],"CV":[16.002082,-24.013197],"CX":[-10.447525,105.690449],"CY":[35.126413,33.429859],"CZ":[49.817492,15.472962],"DE":[51.165691,10.451526],"DJ":[11.825138,42.590275],"DK":[56.26392,9.501785],"DM":[15.414999,-61.370976],"DO":[18.735693,-70.162651],"DZ":[28.033886,1.659626],"EC":[-1.831239,-78.183406],"EE":[58.595272,25.013607],"EG":[26.820553,30.802498],"EH":[24.215527,-12.885834],"ER":[15.179384,39.782334],"ES":[40.463667,-3.74922],"ET":[9.145,40.489673],"FI":[61.92411,25.748151],"FJ":[-16.578193,179.414413],"FK":[-51.796253,-59.523613],"FM":[7.425554,150.550812],"FO":[61.892635,-6.911806],"FR":[46.227638,2.213749],"GA":[-0.803689,11.609444],"GB":[55.378051,-3.435973],"GD":[12.262776,-61.604171],"GE":[42.315407,43.356892],"GF":[3.933889,-53.125782],"GG":[49.465691,-2.585278],"GH":[7.946527,-1.023194],"GI":[36.137741,-5.345374],"GL":[71.706936,-42.604303],"GM":[13.443182,-15.310139],"GN":[9.945587,-9.696645],"GP":[16.995971,-62.067641],"GQ":[1.650801,10.267895],"GR":[39.074208,21.824312],"GS":[-54.429579,-36.587909],"GT":[15.783471,-90.230759],"GU":[13.444304,144.793731],"GW":[11.803749,-15.180413],"GY":[4.860416,-58.93018],"GZ":[31.354676,34.308825],"HK":[22.396428,114.109497],"HM":[-53.08181,73.504158],"HN":[15.199999,-86.241905],"HR":[45.1,15.2],"HT":[18.971187,-72.285215],"HU":[47.162494,19.503304],"ID":[-0.789275,113.921327],"IE":[53.41291,-8.24389],"IL":[31.046051,34.851612],"IM":[54.236107,-4.548056],"IN":[20.593684,78.96288],"IO":[-6.343194,71.876519],"IQ":[33.223191,43.679291],"IR":[32.427908,53.688046],"IS":[64.963051,-19.020835],"IT":[41.87194,12.56738],"JE":[49.214439,-2.13125],"JM":[18.109581,-77.297508],"JO":[30.585164,36.238414],"JP":[36.204824,138.252924],"KE":[-0.023559,37.906193],"KG":[41.20438,74.766098],"KH":[12.565679,104.990963],"KI":[-3.370417,-168.734039],"KM":[-11.875001,43.872219],"KN":[17.357822,-62.782998],"KP":[40.339852,127.510093],"KR":[35.907757,127.766922],"KW":[29.31166,47.481766],"KY":[19.513469,-80.566956],"KZ":[48.019573,66.923684],"LA":[19.85627,102.495496],"LB":[33.854721,35.862285],"LC":[13.909444,-60.978893],"LI":[47.166,9.555373],"LK":[7.873054,80.771797],"LR":[6.428055,-9.429499],"LS":[-29.609988,28.233608],"LT":[55.169438,23.881275],"LU":[49.815273,6.129583],"LV":[56.879635,24.603189],"LY":[26.3351,17.228331],"MA":[31.791702,-7.09262],"MC":[43.750298,7.412841],"MD":[47.411631,28.369885],"ME":[42.708678,19.37439],"MG":[-18.766947,46.869107],"MH":[7.131474,171.184478],"MK":[41.608635,21.745275],"ML":[17.570692,-3.996166],"MM":[21.913965,95.956223],"MN":[46.862496,103.846656],"MO":[22.198745,113.543873],"MP":[17.33083,145.38469],"MQ":[14.641528,-61.024174],"MR":[21.00789,-10.940835],"MS":[16.742498,-62.187366],"MT":[35.937496,14.375416],"MU":[-20.348404,57.552152],"MV":[3.202778,73.22068],"MW":[-13.254308,34.301525],"MX":[23.634501,-102.552784],"MY":[4.210484,101.975766],"MZ":[-18.665695,35.529562],"NA":[-22.95764,18.49041],"NC":[-20.904305,165.618042],"NE":[17.607789,8.081666],"NF":[-29.040835,167.954712],"NG":[9.081999,8.675277],"NI":[12.865416,-85.207229],"NL":[52.132633,5.291266],"NO":[60.472024,8.468946],"NP":[28.394857,84.124008],"NR":[-0.522778,166.931503],"NU":[-19.054445,-169.867233],"NZ":[-40.900557,174.885971],"OM":[21.512583,55.923255],"PA":[8.537981,-80.782127],"PE":[-9.189967,-75.015152],"PF":[-17.679742,-149.406843],"PG":[-6.314993,143.95555],"PH":[12.879721,121.774017],"PK":[30.375321,69.345116],"PL":[51.919438,19.145136],"PM":[46.941936,-56.27111],"PN":[-24.703615,-127.439308],"PR":[18.220833,-66.590149],"PS":[31.952162,35.233154],"PT":[39.399872,-8.224454],"PW":[7.51498,134.58252],"PY":[-23.442503,-58.443832],"QA":[25.354826,51.183884],"RE":[-21.115141,55.536384],"RO":[45.943161,24.96676],"RS":[44.016521,21.005859],"RU":[61.52401,105.318756],"RW":[-1.940278,29.873888],"SA":[23.885942,45.079162],"SB":[-9.64571,160.156194],"SC":[-4.679574,55.491977],"SD":[12.862807,30.217636],"SE":[60.128161,18.643501],"SG":[1.352083,103.819836],"SH":[-24.143474,-10.030696],"SI":[46.151241,14.995463],"SJ":[77.553604,23.670272],"SK":[48.669026,19.699024],"SL":[8.460555,-11.779889],"SM":[43.94236,12.457777],"SN":[14.497401,-14.452362],"SO":[5.152149,46.199616],"SR":[3.919305,-56.027783],"ST":[0.18636,6.613081],"SV":[13.794185,-88.89653],"SY":[34.802075,38.996815],"SZ":[-26.522503,31.465866],"TC":[21.694025,-71.797928],"TD":[15.454166,18.732207],"TF":[-49.280366,69.348557],"TG":[8.619543,0.824782],"TH":[15.870032,100.992541],"TJ":[38.861034,71.276093],"TK":[-8.967363,-171.855881],"TL":[-8.874217,125.727539],"TM":[38.969719,59.556278],"TN":[33.886917,9.537499],"TO":[-21.178986,-175.198242],"TR":[38.963745,35.243322],"TT":[10.691803,-61.222503],"TV":[-7.109535,177.64933],"TW":[23.69781,120.960515],"TZ":[-6.369028,34.888822],"UA":[48.379433,31.16558],"UG":[1.373333,32.290275],"US":[37.09024,-95.712891],"UY":[-32.522779,-55.765835],"UZ":[41.377491,64.585262],"VA":[41.902916,12.453389],"VC":[12.984305,-61.287228],"VE":[6.42375,-66.58973],"VG":[18.420695,-64.639968],"VI":[18.335765,-64.896335],"VN":[14.058324,108.277199],"VU":[-15.376706,166.959158],"WF":[-13.768752,-177.156097],"WS":[-13.759029,-172.104629],"XK":[42.602636,20.902977],"YE":[15.552727,48.516388],"YT":[-12.8275,45.166244],"ZA":[-30.559482,22.937506],"ZM":[-13.133897,27.849332],"ZW":[-19.015438,29.154857]}; + +// Palettes for the dotted globe, tuned to the analytics chart tokens (blue markers). +const THEMES = { + dark: { + dark: 1, + baseColor: [0.45, 0.5, 0.62], + markerColor: [0.36, 0.6, 1], + glowColor: [0.12, 0.16, 0.26], + mapBrightness: 11, + }, + light: { + dark: 0, + baseColor: [0.82, 0.85, 0.9], + markerColor: [0.13, 0.36, 0.92], + glowColor: [1, 1, 1], + mapBrightness: 9, + }, +}; + +/** + * Turn country-breakdown rows into cobe markers. Marker size scales with the + * square root of request volume so a single dominant country doesn't dwarf the rest. + * + * @param {Array<{code: string, requests: number}>} data + * @returns {Array<{location: [number, number], size: number}>} + */ +function buildMarkers(data) { + const rows = (data || []) + .map((r) => ({ code: String(r.code || '').toUpperCase(), requests: Number(r.requests || 0) })) + .filter((r) => r.requests > 0 && CENTROIDS[r.code]); + + if (rows.length === 0) { + return []; + } + + const max = Math.max(...rows.map((r) => r.requests)); + + return rows.map((r) => ({ + location: CENTROIDS[r.code], + size: Math.max(0.03, Math.min(0.11, Math.sqrt(r.requests / max) * 0.11)), + })); +} + +const TWO_PI = Math.PI * 2; + +// cobe orientation for a lat/lng so the point faces the viewer (cobe's own +// focus example formula). Returns [phi, theta]. +function locationToAngles(lat, lng) { + return [Math.PI - ((lng * Math.PI) / 180 - Math.PI / 2), (lat * Math.PI) / 180]; +} + +// Shortest-path angular interpolation, so easing across the 0/2π seam never +// spins the long way around. +function lerpAngle(current, target, t) { + let delta = ((target - current + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; + + return current + delta * t; +} + +/** + * Mount an interactive, drag-to-rotate dotted globe onto a canvas. Returns a + * controller with `update(data, dark)`, `focus(code)`, `resume()` and + * `destroy()`. The globe auto-rotates, pauses while grabbed or while a country + * is hover-focused, and eases smoothly toward whatever it's pointed at. + * + * cobe v2 has no internal render loop or `onRender` callback: createGlobe draws + * a single frame and returns `{ update, destroy }`. We drive our own rAF loop, + * calling `globe.update({...})` each frame for rotation and to swap markers/theme. + * + * @param {HTMLCanvasElement} canvas + * @param {Array<{code: string, requests: number}>} data + * @param {boolean} dark + */ +function mountTrafficGlobe(canvas, data, dark) { + let globe = null; + let width = 0; + let destroyed = false; + let rafId = 0; + let markers = buildMarkers(data); + + // Ambient spin is decorative; honor reduced-motion by not auto-rotating + // (drag + hover-focus still work — those are user-initiated). + const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + // Rotation is a single moving target the loop eases toward each frame. + let targetPhi = 0; + let currentPhi = 0; + let targetTheta = 0.2; + let currentTheta = 0.2; + let autoRotate = !prefersReduced; + + let dragging = null; // clientX at pointerdown + let dragStartPhi = 0; + + const onPointerDown = (e) => { + dragging = e.clientX; + dragStartPhi = targetPhi; + autoRotate = false; + canvas.style.cursor = 'grabbing'; + }; + const onPointerUp = () => { + if (dragging === null) { + return; + } + dragging = null; + autoRotate = ! prefersReduced; + canvas.style.cursor = 'grab'; + }; + const onPointerMove = (e) => { + if (dragging !== null) { + targetPhi = dragStartPhi + (e.clientX - dragging) / 150; + } + }; + + canvas.addEventListener('pointerdown', onPointerDown); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointermove', onPointerMove); + canvas.style.cursor = 'grab'; + + const create = (isDark) => { + const theme = isDark ? THEMES.dark : THEMES.light; + + globe = createGlobe(canvas, { + devicePixelRatio: 2, + width: width, + height: width, + phi: currentPhi, + theta: currentTheta, + diffuse: 1.2, + mapSamples: 16000, + mapBrightness: theme.mapBrightness, + dark: theme.dark, + baseColor: theme.baseColor, + markerColor: theme.markerColor, + glowColor: theme.glowColor, + opacity: 0.92, + markers: markers, + }); + }; + + // Self-driven animation loop (cobe v2 draws only when we call update()). + const tick = () => { + if (destroyed) { + return; + } + if (globe && width > 0) { + if (autoRotate && dragging === null) { + targetPhi += 0.0025; + } + currentPhi = lerpAngle(currentPhi, targetPhi, 0.12); + currentTheta += (targetTheta - currentTheta) * 0.12; + globe.update({ phi: currentPhi, theta: currentTheta, width: width, height: width, markers }); + } + rafId = requestAnimationFrame(tick); + }; + + // cobe needs a non-zero canvas width at creation; inside a freshly-rendered + // or momentarily-hidden container offsetWidth can be 0, which yields a blank + // globe (only the grab cursor shows). Create once a real width is known. + const ensure = () => { + const next = canvas.offsetWidth; + if (destroyed || next === 0 || globe) { + return; + } + width = next; + create(dark); + rafId = requestAnimationFrame(tick); + }; + + const resizeObserver = new ResizeObserver(() => { + if (globe) { + width = canvas.offsetWidth || width; + } else { + ensure(); + } + }); + resizeObserver.observe(canvas); + requestAnimationFrame(ensure); + + return { + update(newData, isDark) { + if (destroyed) { + return; + } + data = newData; + dark = isDark; + markers = buildMarkers(newData); + if (globe) { + const theme = isDark ? THEMES.dark : THEMES.light; + globe.update({ + markers, + dark: theme.dark, + mapBrightness: theme.mapBrightness, + baseColor: theme.baseColor, + markerColor: theme.markerColor, + glowColor: theme.glowColor, + }); + } + }, + focus(code) { + const c = CENTROIDS[String(code || '').toUpperCase()]; + if (!c) { + return; + } + const [phi, theta] = locationToAngles(c[0], c[1]); + targetPhi = phi; + targetTheta = theta; + autoRotate = false; + }, + resume() { + if (dragging === null) { + autoRotate = ! prefersReduced; + } + }, + destroy() { + destroyed = true; + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + resizeObserver.disconnect(); + canvas.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointermove', onPointerMove); + if (globe) { + globe.destroy(); + globe = null; + } + }, + }; +} + +window.mountTrafficGlobe = mountTrafficGlobe; diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index b15afc24de..10480b9204 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -2,6 +2,14 @@
Enter one of the recovery codes you saved when setting up two-factor authentication.

-
+ @csrf
@@ -54,7 +63,7 @@
- + Verify and continue diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 078f3cafa4..4a64a347ca 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -115,6 +115,11 @@ 'route' => 'project.application.metrics', 'active' => $currentRoute === 'project.application.metrics', ], + [ + 'label' => 'Analytics', + 'route' => 'project.application.analytics', + 'active' => $currentRoute === 'project.application.analytics', + ], [ 'label' => 'Tags', 'route' => 'project.application.tags', @@ -154,6 +159,7 @@ 'Resource Limits' => 'cpu', 'Resource Operations' => 'server-update', 'Metrics' => 'graph', + 'Analytics' => 'analytics', 'Tags' => 'tags', 'Danger Zone' => 'shield-alert', ]; @@ -161,7 +167,7 @@ // Discord-style groups for the settings sidebar $menuGroups = [ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'], + 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics', 'Analytics'], 'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], 'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'], @@ -173,6 +179,9 @@ ->values()) ->filter(fn ($items) => $items->isNotEmpty()); + // Group that holds the current page — the only one expanded by default. + $activeGroup = (string) $groupedMenuItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false)); + // In-page sections (cards) shown as sub-items under the active page $isComposeApp = $application->build_pack === 'dockercompose'; $pageSections = [ @@ -204,9 +213,6 @@ ], 'project.application.preview-deployments' => array_values(array_filter([ ['id' => 'preview-template-section', 'label' => 'URL template'], - $application->is_github_based() - ? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests'] - : null, $application->build_pack === 'dockerimage' ? ['id' => 'manual-preview-section', 'label' => 'Manual preview'] : null, @@ -236,6 +242,34 @@ ['id' => 'move-resource-section', 'label' => 'Move resource'], ], ]; + + // Flat, searchable index: every page plus its in-page sub-sections. Each + // entry carries a breadcrumb (its category, and parent page for a + // sub-section) and combined text so the query matches sub-pages too. + $searchIndex = []; + foreach ($groupedMenuItems as $groupLabel => $groupItems) { + foreach ($groupItems as $item) { + $searchIndex[] = [ + 'label' => $item['label'], + 'breadcrumb' => $groupLabel, + 'searchText' => $item['label'].' '.$groupLabel, + 'href' => route($item['route'], $applicationRouteParameters), + 'icon' => $menuIcons[$item['label']] ?? 'settings', + 'navigate' => $item['navigate'] ?? true, + ]; + foreach ($pageSections[$item['route']] ?? [] as $section) { + $searchIndex[] = [ + 'label' => $section['label'], + 'breadcrumb' => $groupLabel.' · '.$item['label'], + 'searchText' => $section['label'].' '.$item['label'].' '.$groupLabel, + 'href' => route($item['route'], $applicationRouteParameters).'#'.$section['id'], + 'icon' => $menuIcons[$item['label']] ?? 'settings', + 'navigate' => true, + ]; + } + } + } + $searchTexts = array_column($searchIndex, 'searchText'); @endphp diff --git a/resources/views/components/application/restart-limit-warning.blade.php b/resources/views/components/application/restart-limit-warning.blade.php new file mode 100644 index 0000000000..5a35623fc4 --- /dev/null +++ b/resources/views/components/application/restart-limit-warning.blade.php @@ -0,0 +1,10 @@ +@props(['application']) + +@if (method_exists($application, 'stoppedAfterRestartLimit') && $application->stoppedAfterRestartLimit()) + @php($restartLimit = method_exists($application, 'restartLimitMaximum') ? $application->restartLimitMaximum() : ($application->max_restart_count ?? 0)) + @php($displayRestartCount = max($application->restart_count ?? 0, $restartLimit)) + +@endif diff --git a/resources/views/components/backup-sidebar.blade.php b/resources/views/components/backup-sidebar.blade.php index 90c86ddb55..a2177b690d 100644 --- a/resources/views/components/backup-sidebar.blade.php +++ b/resources/views/components/backup-sidebar.blade.php @@ -15,7 +15,7 @@ 'danger' => 'project.application.backup.danger', ], 'service' => [ - 'back' => 'project.service.database.backups', + 'back' => 'project.service.volume-backups.index', 'general' => 'project.service.database.backup.show', 's3' => 'project.service.database.backup.s3', 'retention' => 'project.service.database.backup.retention', @@ -48,9 +48,11 @@ ['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'], ]; $backLabel = $context === 'database' ? 'Back to database' : 'Back to backups'; - $backParameters = $context === 'database' - ? collect($parameters)->except('backup_uuid')->all() - : $parameters; + $backParameters = match ($context) { + 'database' => collect($parameters)->except('backup_uuid')->all(), + 'service' => collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all(), + default => $parameters, + }; @endphp diff --git a/resources/views/components/deploying-indicator.blade.php b/resources/views/components/deploying-indicator.blade.php new file mode 100644 index 0000000000..912af3537a --- /dev/null +++ b/resources/views/components/deploying-indicator.blade.php @@ -0,0 +1,34 @@ +@props([ + 'action' => 'reopenDeployment', + 'label' => 'Deploying', + 'href' => null, +]) + +@php + // Persistent affordance shown while a deploy/start is running. Either re-opens + // the in-page live-log dialog (services/databases, via $wire) or links to the + // running deployment's log page (applications, via href) so the log is never lost. + $deployingIndicatorClasses = 'inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium ring-1 transition-colors bg-coollabs/10 text-coollabs ring-coollabs/25 hover:bg-coollabs/15 hover:no-underline dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20'; +@endphp + +@if ($href) + class($deployingIndicatorClasses) }} + title="View the running deployment log"> + + {{ $label }}… + View log + +@else + +@endif diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php index 9976fb6388..5ad53bc787 100644 --- a/resources/views/components/domain-conflict-modal.blade.php +++ b/resources/views/components/domain-conflict-modal.blade.php @@ -47,7 +47,7 @@ {{ $conflict['resource_name'] }} @endif - ({{ $conflict['resource_type'] }}) + ({{ $conflict['resource_type'] }}@if (filled($conflict['service_name'] ?? null)): {{ $conflict['service_name'] }}@endif) @endforeach diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php index b48b041439..e6d4c0af5d 100644 --- a/resources/views/components/dropdown.blade.php +++ b/resources/views/components/dropdown.blade.php @@ -60,15 +60,21 @@ -
true, 'mt-1 w-full' => $inline, 'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline, ]) x-cloak>
! $inline, + 'shadow-[var(--shadow-dropdown)] dark:bg-coolgray-200' => ! $inline, 'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline, $panelClass, ])> diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php index 7a061dc45e..ccf0dc55a4 100644 --- a/resources/views/components/forms/button.blade.php +++ b/resources/views/components/forms/button.blade.php @@ -80,6 +80,10 @@ @if ($authDisabled || filled($tooltip)) diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index 20e090f92b..5a04b99986 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -49,7 +49,7 @@ @endif + class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-300 bg-white shadow-[inset_0_1px_1px_rgb(0_0_0/0.04)] transition-[color,background-color,border-color,box-shadow] duration-150 ease-out group-hover:border-neutral-400 peer-checked:border-coollabs peer-checked:bg-coollabs peer-focus-visible:ring-2 peer-focus-visible:ring-coollabs/25 peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 dark:border-white/[0.14] dark:bg-white/[0.045] dark:shadow-none dark:group-hover:border-white/[0.22] dark:peer-checked:border-warning dark:peer-checked:bg-warning dark:peer-focus-visible:ring-warning/30 dark:peer-focus-visible:ring-offset-base">
@@ -277,7 +277,7 @@ {{-- Dropdown Options --}}
+ class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-dropdown max-h-60 overflow-auto scrollbar">