Compare commits

...

100 commits

Author SHA1 Message Date
rosslh
47dfd034c5 feat(branding): disable FQDN and IP fields instead of hiding/leaving editable
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m49s
2026-03-22 14:03:11 -04:00
rosslh
88e78949bf fix(versioning): move version bumping to CI build time
Version was never bumped because git rebase doesn't run pre-commit
hooks. CI now appends a YYYYMMDDHHmm timestamp to the upstream base
version and injects it into the Docker image via build arg.
2026-03-22 14:03:11 -04:00
rosslh
f744727879 feat(branding): yellow scrollbar and hide FQDN settings field
- Change scrollbar thumb from Coolify red to MapleDeploy yellow
- Hide FQDN field from settings UI (managed during provisioning)
2026-03-22 14:03:11 -04:00
rosslh
736cd4ae67 feat(auth): notify MapleDeploy on first-user registration
After the first user registers, clears the setup token and dispatches
NotifySetupCompleteJob to POST the token to MapleDeploy's callback URL.
Adds setup_callback_url column to instance_settings.
2026-03-22 14:03:11 -04:00
rosslh
671534b578 feat(auth): gate first-user registration with setup token
Require a setup_token query parameter to access the registration page
when no users exist, preventing admin hijacking on new instances.
2026-03-22 14:03:11 -04:00
rosslh
ffc86ec112 fix(dns): replace Cloudflare defaults with CIRA Canadian Shield
Replace hardcoded 1.1.1.1/1.0.0.1 (Cloudflare) DNS defaults with
CIRA Canadian Shield (149.112.121.10, 149.112.122.10) to maintain
Canadian data sovereignty for DNS resolution.
2026-03-22 14:03:11 -04:00
rosslh
674471e063 chore(branding): remove sponsorship popup remnants 2026-03-22 14:03:11 -04:00
rosslh
9e5b6bc98b style(branding): replace Coolify purple and yellow with MapleDeploy palette
Replace #6b16ed (Coolify purple) with #d52b1e (MapleDeploy red) and
form focus states, dirty indicators, chart colors, and theme tokens.
Also fix warning color scale to use standard Tailwind yellow values.
2026-03-22 14:03:11 -04:00
rosslh
644a2f5264 feat(onboarding): skip boarding wizard for new users
Customers land directly on the dashboard instead of the setup wizard,
which shows a misleading localhost vs remote server warning that
conflicts with MapleDeploy's single-VM model.
2026-03-22 14:03:10 -04:00
rosslh
78730601f7 style(branding): add header logo gap and use stone-900 background
Add 4px gap between logo and header elements with flex-shrink support.
Change base background from near-black (#0c0a09) to stone-900 (#1c1917)
to match the marketing site's darkest dark-mode color.
2026-03-22 14:03:10 -04:00
rosslh
9c1fa607b0 fix(branding): preserve sidebar logo aspect ratio when constrained 2026-03-22 14:03:10 -04:00
rosslh
a14338ba7e fix(branding): replace "self-hosted" subtitle on dashboard 2026-03-22 14:03:10 -04:00
rosslh
cd5f855039 fix(branding): constrain sidebar logo to prevent overflow
Replace max-w-none with max-w-full so the logo respects the sidebar
container width instead of overflowing it.
2026-03-22 14:03:10 -04:00
rosslh
50e5397fba fix(branding): preserve sidebar logo aspect ratio, bump to 463.2
Add max-w-none to logo images to prevent Tailwind base styles from
constraining the image width within the narrow sidebar flex container.
2026-03-22 14:03:10 -04:00
rosslh
c5dce7b4dd chore: remove broken feedback form and all entrypoints
The feedback form relies on instance SMTP being configured, which
MapleDeploy customers won't do. Without SMTP it silently logs to
the server and shows a misleading success message. Removed the
navbar button, onboarding modal, subscription page link, API
endpoint, and all associated dead code.
2026-03-22 14:03:10 -04:00
rosslh
e52b219a72 fix(ci): recreate CDN storage zone in EU (DE) region
Storage zone was accidentally created in NY. Recreated as
'coolify-update' in DE (Falkenstein) to keep update artifacts
in EU jurisdiction, consistent with Bunny.net data sovereignty
justification. Updated storage zone name, pull zone ID, and
reverted storage endpoint to default (storage.bunnycdn.com).
2026-03-22 14:03:10 -04:00
rosslh
1d67569190 fix(ci): use NY region storage endpoint for Bunny CDN uploads 2026-03-22 14:03:10 -04:00
rosslh
c957a3472e fix(ci): use apk instead of apt-get for curl install 2026-03-22 14:03:10 -04:00
rosslh
49cda377b0 fix(ci): install curl in runner for CDN uploads 2026-03-22 14:03:10 -04:00
rosslh
5574b61123 feat: add update pipeline with MapleDeploy CDN and versioning
Route all Coolify update artifacts (versions.json, upgrade.sh,
compose files) through updates.mapledeploy.ca instead of upstream
cdn.coollabs.io. Extend CI to publish artifacts to Bunny CDN
storage zone and purge cache on each build.

- Point CDN_URL, versions_url, upgrade_script_url to updates.mapledeploy.ca
- Hardcode helper/realtime images to ghcr.io (not mirrored to Forgejo)
- Pass registry_url as 3rd arg to upgrade.sh for main image pulls
- Adopt versioning scheme 4.0.0-beta.X.N (bump to 4.0.0-beta.463.1)
- Add CI steps: generate versions.json, upload to Bunny, purge cache
2026-03-22 14:03:10 -04:00
rosslh
f23605575d feat(branding): apply MapleDeploy branding to Coolify
Replace Coolify branding with MapleDeploy throughout the UI: logos,
favicon, fonts (Overlock 900), color scheme, help links, and page
titles. Remove GitHub Actions workflows and add Forgejo CI build
workflow. Strip cloud-only features (subscription prompts, sponsor
links, server creation cloud options).
2026-03-22 14:03:10 -04:00
Andras Bacsai
06f60c9a98
v4.0.0-beta.469 (#9007) 2026-03-20 16:38:29 +01:00
Andras Bacsai
8be226788e
fix(deployment): disable build server during restart operations (#9045) 2026-03-20 16:16:46 +01:00
Andras Bacsai
93a8fcd8a2
perf(breadcrumb): optimize queries and simplify navigation to fix OOM (#9048) 2026-03-20 16:16:10 +01:00
Andras Bacsai
1511797e0a fix(docker): skip cleanup stale warning on cloud instances 2026-03-20 15:59:23 +01:00
Andras Bacsai
fef8e0b622 refactor: remove verbose logging and use explicit exception types
- Remove verbose warning/debug logs from ServerConnectionCheckJob and ContainerStatusAggregator
- Silently ignore expected errors (e.g., deleted Hetzner servers)
- Replace generic RuntimeException with DeploymentException for deployment command failures
- Catch both RuntimeException and DeploymentException in command retry logic
2026-03-20 15:57:26 +01:00
Andras Bacsai
6aa618e57f feat(jobs): add cache-based deduplication for delayed cron execution
Implements getPreviousRunDate() + cache-based tracking in shouldRunNow()
to prevent duplicate dispatch of scheduled jobs when queue delays push
execution past the cron minute. This resilience ensures jobs catch missed
windows without double-dispatching within the same cron window.

Updated scheduled job dispatches to include dedupKey parameter:
- Docker cleanup operations
- Server connection checks
- Sentinel restart checks
- Server storage checks
- Server patch checks

DockerCleanupJob now dispatches on the 'high' queue for faster processing.

Includes comprehensive test coverage for dedup behavior across different
cron schedules and delay scenarios.
2026-03-20 15:44:10 +01:00
Andras Bacsai
e65ad22b42 refactor(breadcrumb): optimize queries and simplify state management
- Add column selection to breadcrumb queries for better performance
- Remove unused Alpine.js state (activeRes, activeMenuEnv, resPositions, menuPositions)
- Simplify dropdown logic by removing duplicate state handling in index view
- Change database relationship eager loading to use explicit column selection
2026-03-20 00:02:18 +01:00
Andras Bacsai
65ed407ec8 fix(deployment): disable build server during restart operations
The just_restart() method doesn't need the build server—disabling it ensures
the helper container is created on the deployment server with the correct
network configuration and flags. The build server setting is restored before
should_skip_build() is called in case it triggers a full rebuild that requires it.
2026-03-19 23:42:11 +01:00
Andras Bacsai
2bc8fe3dd7
fix(backup): throw explicit error when S3 storage missing or deleted (#9038) 2026-03-19 23:39:36 +01:00
Andras Bacsai
c00d5de03e feat(api): add database environment variable management endpoints
Add CRUD API endpoints for managing environment variables on databases:
- GET /databases/{uuid}/envs - list environment variables
- POST /databases/{uuid}/envs - create environment variable
- PATCH /databases/{uuid}/envs - update environment variable
- PATCH /databases/{uuid}/envs/bulk - bulk create environment variables
- DELETE /databases/{uuid}/envs/{env_uuid} - delete environment variable

Includes comprehensive test suite and OpenAPI documentation.
2026-03-19 23:29:50 +01:00
Andras Bacsai
fb76b68c08 feat(api): support comments in bulk environment variable endpoints
Add support for optional comment field on environment variables created or
updated through the bulk API endpoints. Comments are validated to a maximum
of 256 characters and are nullable. Updates preserve existing comments when
not provided in the request.
2026-03-19 22:17:55 +01:00
Andras Bacsai
8a164735cb fix(api): extract resource UUIDs from route parameters
Extract resource UUIDs from route parameters instead of request body
in ApplicationsController and ServicesController environment variable
endpoints. This prevents UUID parameters from being spoofed in the
request body.

- Replace $request->uuid with $request->route('uuid')
- Replace $request->env_uuid with $request->route('env_uuid')
- Add tests verifying route parameters are used and body UUIDs ignored
2026-03-19 21:56:58 +01:00
Andras Bacsai
ce5e736b00 feat(storage): add storage management for backup schedules
Add ability to move backups between S3 storages and disable S3 backups.
Refactor storage resources view from cards to table layout with search
functionality and storage selection dropdowns.
2026-03-19 12:48:52 +01:00
Andras Bacsai
86c8ec9c20 feat(storage): group backups by database and filter by s3 status
Group backup schedules by their parent database (type + ID) for better
organization in the UI. Filter to only display backups with save_s3
enabled. Restructure the template to show database name as a header with
nested backups underneath, allowing clearer visualization of which
backups belong to each database. Add key binding to livewire component
to ensure proper re-rendering when resources change.
2026-03-19 12:04:16 +01:00
Andras Bacsai
ca3ae289eb feat(storage): add resources tab and improve S3 deletion handling
Add new Resources tab to storage show page displaying backup schedules using
that storage. Refactor storage show layout with navigation tabs for General
and Resources sections. Move delete action from form to show component.

Implement cascade deletion in S3Storage model to automatically disable S3
backups when storage is deleted. Improve error handling in DatabaseBackupJob
to throw exception when S3 storage is missing instead of silently returning.

- New Storage/Resources Livewire component
- Add resources.blade.php view
- Add storage.resources route
- Move delete() method from Form to Show component
- Add deleting event listener to S3Storage model
- Track backup count and current route in Show component
- Add #[On('submitStorage')] attribute to form submission
2026-03-19 11:42:29 +01:00
Andras Bacsai
d0b4dc1c63
fix(stripe): add error handling and resilience to subscription operations (#9030) 2026-03-18 15:38:59 +01:00
Andras Bacsai
566744b2e0 fix(stripe): add error handling and resilience to subscription operations
- Record refunds immediately before cancellation to prevent retry issues if cancel fails
- Wrap Stripe API calls in try-catch for refunds and quantity reverts with internal notifications
- Add null check in Team.subscriptionEnded() to prevent NPE when subscription doesn't exist
- Fix control flow bug in StripeProcessJob (add missing break statement)
- Cap dynamic server limit with MAX_SERVER_LIMIT in subscription updates
- Add comprehensive tests for refund failures, event handling, and null safety
2026-03-18 15:21:59 +01:00
Andras Bacsai
426a708374 feat(subscription): display next billing date and billing interval
Add current_period_end to refund eligibility checks and display next billing
date and billing interval in the subscription overview. Refactor the plan
overview layout to show subscription status more prominently.
2026-03-18 15:11:19 +01:00
Andras Bacsai
23f9156c73 Squashed commit from 'qqrq-r9h4-x6wp-authenticated-rce' 2026-03-18 13:53:01 +01:00
Andras Bacsai
f896d47b99 feat(environment-variable): add placeholder hint for magic variables
Add a placeholder message to the comment field indicating when an
environment variable is handled by Coolify and cannot be edited manually.
2026-03-17 10:11:26 +01:00
Andras Bacsai
ed3b5d096c refactor(environment-variable): remove buildtime/runtime options and improve comment field
Remove buildtime and runtime availability checkboxes from service-type environment variables across all permission levels. Always show the comment field with a conditional placeholder for magic variables instead of hiding it. Add a lock button for service-type variables.
2026-03-17 09:52:29 +01:00
Andras Bacsai
d4bf011a8e Merge remote-tracking branch 'origin/next' into next 2026-03-16 21:38:07 +01:00
Andras Bacsai
fd3fc17b2f
fix: prevent sporadic SSH permission denied on key rotation (#8990) 2026-03-16 21:37:29 +01:00
Andras Bacsai
6325e41aec fix(ssh): handle chmod failures gracefully and simplify key management
- Log warnings instead of silently failing when chmod 0600 fails
- Remove redundant refresh() call before SSH key validation
- Remove storeInFileSystem() call from updatePrivateKey() transaction
- Remove @unlink() of lock file after filesystem store
- Refactor unit tests to use real temp disk and anonymous class stub
  instead of reflection-only checks
2026-03-16 21:27:10 +01:00
Andras Bacsai
b448322a0c docs(sponsors): add ScreenshotOne as a huge sponsor 2026-03-16 21:22:42 +01:00
Andras Bacsai
2ba62ffe02 Merge remote-tracking branch 'origin/next' into next 2026-03-16 21:22:32 +01:00
Andras Bacsai
9976645c25 Merge remote-tracking branch 'origin/next' into fix/ssh-sporadic-permission-denied 2026-03-16 21:15:30 +01:00
Andras Bacsai
c09127c54b
fix(preview): enable per-volume control of PR suffix in preview deployments (#9006) 2026-03-16 21:13:37 +01:00
Andras Bacsai
15d6de9f41 fix(storages): hide PR suffix for services and fix instantSave logic
- Restrict "Add suffix for PR deployments" checkbox to non-service
  resources in both shared and service file-storage views
- Replace condition `is_preview_deployments_enabled` with `!$isService`
  for PR suffix visibility in storages/show.blade.php
- Fix FileStorage::instantSave() to use authorize + syncData instead
  of delegating to submit(), preventing unintended side effects
- Add $this->validate() to Storages/Show::instantSave() before saving
- Add response content schemas to storages API OpenAPI annotations
- Add additionalProperties: false to storage update request schema
- Rewrite PreviewDeploymentBindMountTest with behavioral tests of
  addPreviewDeploymentSuffix instead of file-content inspection
2026-03-16 21:10:00 +01:00
Andras Bacsai
fe1aa94144 Merge remote-tracking branch 'origin/next' into fix/ssh-sporadic-permission-denied 2026-03-16 20:26:56 +01:00
Andras Bacsai
0ffcee7a4d Squashed commit from '4fhp-investigate-os-command-injection' 2026-03-16 16:40:16 +01:00
Andras Bacsai
3ffe900b31 Merge remote-tracking branch 'origin/next' into 7802-volume-mappings-bug 2026-03-16 15:40:15 +01:00
Andras Bacsai
38df686718 Merge remote-tracking branch 'origin/next' into next 2026-03-16 15:40:05 +01:00
Andras Bacsai
1b0b230de2 fix(compose): include git branch in compose file not found error
Add the git branch to the "Docker Compose file not found" error message
to help diagnose cases where the file exists on one branch but not the
checked-out branch.
2026-03-16 15:39:24 +01:00
Andras Bacsai
9d745fca75 feat(api): expand update_storage to support name, mount_path, host_path, content fields
Add support for updating additional storage fields via the API while
enforcing read-only restrictions for storages managed by docker-compose
or service definitions (only is_preview_suffix_enabled remains editable
for those).
2026-03-16 15:37:46 +01:00
Andras Bacsai
0488a188a0 feat(api): add storages endpoints for applications
Add GET and PATCH /applications/{uuid}/storages routes to list and
update persistent and file storages for an application, including
support for toggling is_preview_suffix_enabled.
2026-03-16 15:34:27 +01:00
Andras Bacsai
c9861e08e3 fix(preview): sync isPreviewSuffixEnabled property on file storage save 2026-03-16 15:13:36 +01:00
Andras Bacsai
add16853a8 feat(preview): add configurable PR suffix toggle for volumes
Add `is_preview_suffix_enabled` flag to `local_file_volumes` and
`local_persistent_volumes` tables, allowing per-volume control over
whether a `-pr-N` suffix is appended during preview deployments.

Defaults to `true` to preserve existing behavior. Users can disable
it for volumes containing shared config or repository scripts that
should not be isolated per PR.
2026-03-16 14:54:22 +01:00
Andras Bacsai
c61d71966e
feat(service): update databasus to v3.16.2 (#8586) 2026-03-16 13:46:14 +01:00
Andras Bacsai
b5e03c3a56
feat(service): Add librespeed (#8626) 2026-03-16 13:46:02 +01:00
Andras Bacsai
44a36d0df9
chore(service): Update SeaweedFS images to version 4.13 (#8738) 2026-03-16 13:45:54 +01:00
Andras Bacsai
a8d3522653
fix(template): fix heyform template (#8747) 2026-03-16 13:45:44 +01:00
Andras Bacsai
2d1bad5f39
feat(templates): Add imgcompress service, for offline image processing (#8763) 2026-03-16 13:45:34 +01:00
Andras Bacsai
1b484a56b0 chore: remove coolify-examples-1 submodule 2026-03-16 13:37:14 +01:00
Andras Bacsai
5b424f1f0e fix(preview): exclude bind mounts from preview deployment suffix
Bind mount volumes reference files at the repository's original path and
should not receive the -pr-N suffix. Only named Docker volumes require
the suffix for isolation between preview deployments.

Adds PreviewDeploymentBindMountTest to verify the correct behavior.

Fixes #7802
2026-03-16 13:33:58 +01:00
pannous
2f96a759df test: add behavioral ssh key stale-file regression 2026-03-16 10:40:22 +01:00
pannous
4bf94fac2d fix: prevent sporadic SSH permission denied by validating key content
The root cause of sporadic "Permission denied (publickey)" errors was
that validateSshKey() only checked if the key file existed on disk,
never verifying its content matched the database. When keys were rotated
or updated, the stale file persisted and SSH used the wrong key.

Changes:
- validateSshKey() now refreshes key from DB and compares file content
- Server saved event detects private_key_id changes to invalidate mux
- PrivateKey storeInFileSystem() uses file locking to prevent races
- PrivateKey saved event auto-resyncs file on key content changes
- Enforces 0600 permissions on key files

Fixes coollabsio/coolify#7724
2026-03-15 03:06:21 +01:00
Andras Bacsai
74d591e6e9
feat(server): auto-fetch server metadata after validation (#8964) 2026-03-13 17:08:15 +01:00
Andras Bacsai
1936bb08bf feat(server): auto-fetch server metadata after validation
Server metadata is now automatically gathered when server validation completes successfully, both in the async job and Livewire component. This ensures server details (OS, CPU count, etc.) are populated immediately after validation passes, improving the user experience without requiring manual metadata fetching.

Tests added to verify gatherServerMetadata is called on successful validation and skipped when validation fails.
2026-03-13 17:07:50 +01:00
Andras Bacsai
9c0966c08a
feat(compose-preview): populate fqdn from docker_compose_domains (#8963) 2026-03-13 17:02:26 +01:00
Andras Bacsai
c39a287b47 feat(compose-preview): populate fqdn from docker_compose_domains
The generate_preview_fqdn_compose method now extracts and populates the fqdn field from docker_compose_domains, making it available for webhook notifications. This handles multiple domains across services and gracefully sets fqdn to null when no domains are configured.
2026-03-13 17:02:05 +01:00
Andras Bacsai
cde0bebfd4
feat(server): allow force deletion of servers with resources (#8962) 2026-03-13 17:00:37 +01:00
Andras Bacsai
b8390482b8 feat(server): allow force deletion of servers with resources
Add ability to force delete servers along with their defined resources:
- API: Accept ?force=true query parameter in DELETE /servers endpoint
- UI: Display checkbox option to delete all resources in deletion dialog

When force deletion is enabled, all associated resources are dispatched
via DeleteResourceJob before the server is removed, enabling one-step
deletion instead of requiring manual resource cleanup first.
2026-03-13 16:58:26 +01:00
Andras Bacsai
d5b3a0380c
fix(docker-compose): respect preserveRepository when injecting --project-directory (#8956) 2026-03-13 13:55:50 +01:00
Andras Bacsai
c8046c6cd1
fix(api): allow is_container_label_escape_enabled in service operations (#8955) 2026-03-13 13:55:46 +01:00
Andras Bacsai
a97612b29e fix(docker-compose): respect preserveRepository when injecting --project-directory
When adding --project-directory to custom docker compose start commands,
use the application's host workdir if preserveRepository is true, otherwise
use the container workdir. Add tests for both scenarios and explicit paths.
2026-03-13 13:53:03 +01:00
Andras Bacsai
b9cae51c5d feat(service): add container label escape control to services API
Add `is_container_label_escape_enabled` boolean field to services API,
allowing users to control whether special characters in container labels
are escaped. Defaults to true (escaping enabled).

When disabled, users can use environment variables within labels.
Includes validation rules and comprehensive test coverage.
2026-03-13 13:32:58 +01:00
ShadowArcanist
c25e59e7ed
chore(service): pin imgcompress to a static version instead of latest 2026-03-13 12:28:25 +05:30
ShadowArcanist
35eb5cf937
chore(service): remove unused attributes on imgcompress service 2026-03-13 12:27:55 +05:30
Andras Bacsai
6408718ad1
fix(service): hoppscotch fails to start due to db unhealthy (#8949) 2026-03-13 07:56:47 +01:00
Andras Bacsai
0cfc930ec8
Change Castopod service port from 8000 to 8080 (#8817) 2026-03-13 07:55:47 +01:00
Andras Bacsai
413d0747cf
feat(service): update n8n-with-postgres-and-worker to 2.10.4 (#8807) 2026-03-13 07:55:23 +01:00
ShadowArcanist
963e335621
chore(service): pin castopod service to a static version instead of latest 2026-03-13 12:05:06 +05:30
Andras Bacsai
8363367cd0
fix(git): GitHub App webhook endpoint defaults to IPv4 instead of the instance domain (#8948) 2026-03-13 07:11:50 +01:00
ShadowArcanist
f1b8aaed2e
fix(service): hoppscotch fails to start due to db unhealthy 2026-03-13 11:40:25 +05:30
ShadowArcanist
c3d8f70ebb fix(git): GitHub App webhook endpoint defaults to IPv4 instead of the instance domain 2026-03-13 11:19:00 +05:30
Andras Bacsai
21ed8fd300 version++ 2026-03-12 15:10:12 +01:00
Andras Bacsai
9ea8e4dabf add dataforest sponsor 2026-03-12 15:10:06 +01:00
Bernhard Millauer
db46af89b1
Change Castopod service port from 8000 to 8080
The current port mapping 8000 is wrong and the service is never reachable.

As stated in https://docs.castopod.org/main/en/getting-started/docker/, the docker container is exposing 8080.
2026-03-08 18:54:14 +01:00
Michael Chan
18118f1d61
fixup 2026-03-08 01:08:18 -05:00
Ariq Pradipa Santoso
5585e68b38 Add imgcompress service configuration for offline image processing
- Introduced a new YAML configuration file for imgcompress service.
- Configured the service with environment variables for customization.
2026-03-04 12:07:52 +07:00
Fábio Henrique Araújo
dd1f5d9bbf
Merge branch 'coollabsio:next' into next 2026-03-03 09:08:41 -03:00
mufeng
01459df60d fix(template): fix heyform template 2026-03-03 17:57:00 +08:00
Fábio Henrique Araújo
45d991565e
Update SeaweedFS images to version 4.13 2026-03-02 20:04:29 -03:00
Diogo Carvalho
34e9f97b79
Fix logo format 2026-02-25 23:48:15 +00:00
Diogo Carvalho
1a8441b230
Rename speedtest to librespeed 2026-02-25 23:28:20 +00:00
Diogo Carvalho
e9c980d3d5
Add logo 2026-02-25 23:25:08 +00:00
Diogo Carvalho
907b3d04c1
Add speedtest service 2026-02-25 23:22:38 +00:00
Firu
7ad6bbafcd
chore(templates): bump databasus image version 2026-02-24 11:25:17 +08:00
240 changed files with 5839 additions and 2794 deletions

View file

@ -15,4 +15,5 @@ ROOT_USERNAME=
ROOT_USER_EMAIL=
ROOT_USER_PASSWORD=
REGISTRY_URL=ghcr.io
REGISTRY_URL=forgejo.mapledeploy.ca
CDN_URL=https://updates.mapledeploy.ca

View file

@ -0,0 +1,103 @@
name: Build MapleDeploy Coolify Image
on:
push:
branches: [mapledeploy]
paths-ignore:
- "*.md"
- ".github/**"
- "templates/**"
env:
REGISTRY: forgejo.mapledeploy.ca
CDN_STORAGE_ZONE: coolify-update
CDN_PULL_ZONE_ID: "5338895"
CDN_BASE_URL: https://updates.mapledeploy.ca
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get version
id: version
run: |
BASE_VERSION=$(sed -n "s/.*'version' => '\([^']*\)'.*/\1/p" config/constants.php)
TIMESTAMP=$(date -u +%Y%m%d%H%M)
VERSION="${BASE_VERSION}.${TIMESTAMP}"
HELPER_VERSION=$(sed -n "s/.*'helper_version' => '\([^']*\)'.*/\1/p" config/constants.php)
REALTIME_VERSION=$(sed -n "s/.*'realtime_version' => '\([^']*\)'.*/\1/p" config/constants.php)
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
echo "HELPER_VERSION=${HELPER_VERSION}" >> "$GITHUB_OUTPUT"
echo "REALTIME_VERSION=${REALTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Building version: ${VERSION} (helper: ${HELPER_VERSION}, realtime: ${REALTIME_VERSION})"
- name: Login to Forgejo registry
run: |
echo "${{ secrets.FORGEJO_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.repository_owner }} --password-stdin
- name: Build image
run: |
DOCKER_BUILDKIT=1 docker build -f docker/production/Dockerfile \
--build-arg MAPLEDEPLOY_VERSION=${{ steps.version.outputs.VERSION }} \
-t ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }} \
-t ${{ env.REGISTRY }}/${{ github.repository }}:latest \
.
- name: Push image
run: |
docker push ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }}
docker push ${{ env.REGISTRY }}/${{ github.repository }}:latest
- name: Generate versions.json
run: |
cat > versions.json <<EOF
{
"coolify": {
"v4": {
"version": "${{ steps.version.outputs.VERSION }}"
},
"helper": {
"version": "${{ steps.version.outputs.HELPER_VERSION }}"
},
"realtime": {
"version": "${{ steps.version.outputs.REALTIME_VERSION }}"
}
}
}
EOF
echo "Generated versions.json:"
cat versions.json
- name: Install curl
run: apk add --no-cache curl
- name: Upload artifacts to Bunny CDN
run: |
STORAGE_URL="https://storage.bunnycdn.com/${{ env.CDN_STORAGE_ZONE }}/coolify"
upload() {
local file="$1"
local dest="$2"
echo "Uploading ${file} -> ${dest}"
curl -fsSL -X PUT "${STORAGE_URL}/${dest}" \
-H "AccessKey: ${{ secrets.BUNNY_CDN_STORAGE_KEY }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${file}"
}
upload versions.json versions.json
upload scripts/upgrade.sh upgrade.sh
upload docker-compose.yml docker-compose.yml
upload docker-compose.prod.yml docker-compose.prod.yml
upload .env.production .env.production
echo "All artifacts uploaded."
- name: Purge CDN cache
run: |
curl -fsSL -X POST "https://api.bunny.net/pullzone/${{ env.CDN_PULL_ZONE_ID }}/purgeCache" \
-H "AccessKey: ${{ secrets.BUNNY_API_KEY }}" \
-H "Content-Type: application/json"
echo "CDN cache purged."

View file

@ -1,22 +0,0 @@
name: Lock closed Issues, Discussions, and PRs
on:
schedule:
- cron: '0 1 * * *'
permissions:
issues: write
discussions: write
pull-requests: write
jobs:
lock-threads:
runs-on: ubuntu-latest
steps:
- name: Lock threads after 30 days of inactivity
uses: dessant/lock-threads@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
discussion-inactive-days: '30'
pr-inactive-days: '30'

View file

@ -1,32 +0,0 @@
name: Manage Stale Issues and PRs
on:
schedule:
- cron: '0 2 * * *'
permissions:
issues: write
pull-requests: write
jobs:
manage-stale:
runs-on: ubuntu-latest
steps:
- name: Manage stale issues and PRs
uses: actions/stale@v9
id: stale
with:
stale-issue-message: 'This issue will be automatically closed in a few days if no response is received. Please provide an update with the requested information.'
stale-pr-message: 'This pull request requires attention. If no changes or response is received within the next few days, it will be automatically closed. Please update your PR or leave a comment with the requested information.'
close-issue-message: 'This issue has been automatically closed due to inactivity.'
close-pr-message: 'Thank you for your contribution. Due to inactivity, this PR was automatically closed. If you would like to continue working on this change in the future, feel free to reopen this PR or submit a new one.'
days-before-stale: 14
days-before-close: 7
stale-issue-label: '⏱︎ Stale'
stale-pr-label: '⏱︎ Stale'
only-labels: '💤 Waiting for feedback, 💤 Waiting for changes'
remove-stale-when-updated: true
operations-per-run: 100
labels-to-remove-when-unstale: '⏱︎ Stale, 💤 Waiting for feedback, 💤 Waiting for changes'
close-issue-reason: 'not_planned'
exempt-all-milestones: false

View file

@ -1,49 +0,0 @@
name: Add comment based on label
on:
pull_request_target:
types:
- labeled
permissions:
pull-requests: write
jobs:
add-comment:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- label: "⚙️ Service"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are either adding a new service or making changes to an existing one.
We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation
- label: "🛠️ Feature"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are adding a new feature to Coolify.
We kindly ask you to also update the **Coolify Documentation** to include information about this new feature.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation
# - label: "✨ Enhancement"
# body: |
# It appears to us that you are making an enhancement to Coolify.
# We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable.
# This will help ensure that our documentation remains accurate and up-to-date for all users.
steps:
- name: Add comment
if: github.event.label.name == matrix.label
run: gh pr comment "$NUMBER" --body "$BODY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.pull_request.number }}
BODY: ${{ matrix.body }}

View file

@ -1,37 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: '--model opus'

View file

@ -1,22 +0,0 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch:
permissions:
packages: write
jobs:
cleanup-all-packages:
runs-on: ubuntu-latest
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
steps:
- name: Delete untagged ${{ matrix.package }} images
uses: actions/delete-package-versions@v5
with:
package-name: ${{ matrix.package }}
package-type: 'container'
min-versions-to-keep: 0
delete-only-untagged-versions: 'true'

View file

@ -1,117 +0,0 @@
name: Coolify Helper Image Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-helper-next.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,116 +0,0 @@
name: Coolify Helper Image
on:
push:
branches: [ "v4.x" ]
paths:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,122 +0,0 @@
name: Production Build (v4)
on:
push:
branches: ["v4.x"]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,120 +0,0 @@
name: Coolify Realtime Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-realtime-next.yml
- docker/coolify-realtime/Dockerfile
- docker/coolify-realtime/terminal-server.js
- docker/coolify-realtime/package.json
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,120 +0,0 @@
name: Coolify Realtime
on:
push:
branches: [ "v4.x" ]
paths:
- .github/workflows/coolify-realtime.yml
- docker/coolify-realtime/Dockerfile
- docker/coolify-realtime/terminal-server.js
- docker/coolify-realtime/package.json
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,134 +0,0 @@
name: Staging Build
on:
push:
branches-ignore:
- v4.x
- v3.x
- '**v5.x**'
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,104 +0,0 @@
name: Coolify Testing Host
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-testing-host.yml
- docker/testing-host/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-testing-host"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/testing-host/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,42 +0,0 @@
name: Generate Changelog
on:
push:
branches: [ v4.x ]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
workflow_dispatch:
permissions:
contents: write
jobs:
changelog:
name: Generate changelog
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --verbose
env:
OUTPUT: CHANGELOG.md
GITHUB_REPO: ${{ github.repository }}
- name: Commit
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add CHANGELOG.md
git commit -m "docs: update changelog"
git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git v4.x

View file

@ -59,6 +59,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
*
### Big Sponsors
@ -77,6 +78,7 @@ ### Big Sponsors
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer

View file

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use App\Jobs\NotifySetupCompleteJob;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@ -35,6 +36,14 @@ public function create(array $input): User
])->validate();
if (User::count() == 0) {
// MapleDeploy: validate setup token for first user registration
if ($settings->setup_token) {
$providedToken = $input['setup_token'] ?? null;
if (! $providedToken || ! hash_equals($settings->setup_token, $providedToken)) {
abort(403);
}
}
// If this is the first user, make them the root user
// Team is already created in the database/seeders/ProductionSeeder.php
$user = User::create([
@ -48,7 +57,18 @@ public function create(array $input): User
// Disable registration after first user is created
$settings = instanceSettings();
$settings->is_registration_enabled = false;
// MapleDeploy: notify control plane that setup is complete
// Capture token before clearing so the job can authenticate with it
$callbackUrl = $settings->setup_callback_url;
$token = $settings->setup_token;
$settings->setup_token = null;
$settings->setup_callback_url = null;
$settings->save();
if ($callbackUrl && $token) {
NotifySetupCompleteJob::dispatch($token, $callbackUrl);
}
} else {
$user = User::create([
'name' => $input['name'],

View file

@ -102,7 +102,8 @@ public function handle(Server $server, $fromUI = false): bool
foreach ($conflicts as $port => $conflict) {
if ($conflict) {
if ($fromUI) {
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
// MapleDeploy branding: support links
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Support: <a target='_blank' class='dark:text-white hover:underline' href='https://mapledeploy.ca/contact'>https://mapledeploy.ca/contact</a>");
} else {
return false;
}

View file

@ -119,9 +119,11 @@ private function update()
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = config('constants.coolify.registry_url');
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion $registryUrl",
], $this->server);
}
}

View file

@ -19,7 +19,7 @@ public function __construct(?StripeClient $stripe = null)
/**
* Check if the team's subscription is eligible for a refund.
*
* @return array{eligible: bool, days_remaining: int, reason: string}
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
public function checkEligibility(Team $team): array
{
@ -43,8 +43,10 @@ public function checkEligibility(Team $team): array
return $this->ineligible('Subscription not found in Stripe.');
}
$currentPeriodEnd = $stripeSubscription->current_period_end;
if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.");
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
}
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
@ -52,13 +54,14 @@ public function checkEligibility(Team $team): array
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
if ($daysRemaining <= 0) {
return $this->ineligible('The 30-day refund window has expired.');
return $this->ineligible('The 30-day refund window has expired.', $currentPeriodEnd);
}
return [
'eligible' => true,
'days_remaining' => $daysRemaining,
'reason' => 'Eligible for refund.',
'current_period_end' => $currentPeriodEnd,
];
}
@ -99,16 +102,27 @@ public function execute(Team $team): array
'payment_intent' => $paymentIntentId,
]);
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
// Record refund immediately so it cannot be retried if cancel fails
$subscription->update([
'stripe_refunded_at' => now(),
'stripe_feedback' => 'Refund requested by user',
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
]);
try {
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
} catch (\Exception $e) {
\Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage());
send_internal_notification(
"CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required."
);
}
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
'stripe_feedback' => 'Refund requested by user',
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
'stripe_refunded_at' => now(),
]);
$team->subscriptionEnded();
@ -128,14 +142,15 @@ public function execute(Team $team): array
}
/**
* @return array{eligible: bool, days_remaining: int, reason: string}
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
private function ineligible(string $reason): array
private function ineligible(string $reason, ?int $currentPeriodEnd = null): array
{
return [
'eligible' => false,
'days_remaining' => 0,
'reason' => $reason,
'current_period_end' => $currentPeriodEnd,
];
}
}

View file

@ -153,12 +153,19 @@ public function execute(Team $team, int $quantity): array
\Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}.");
// Revert subscription quantity on Stripe
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $previousQuantity],
],
'proration_behavior' => 'none',
]);
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $previousQuantity],
],
'proration_behavior' => 'none',
]);
} catch (\Exception $revertException) {
\Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage());
send_internal_notification(
"CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required."
);
}
// Void the unpaid invoice
if ($latestInvoice->id) {

View file

@ -263,15 +263,11 @@ private function restoreCoolifyDbBackup()
}
}
// MapleDeploy branding: telemetry disabled — no phone-home signal
private function sendAliveSignal()
{
$id = config('app.id');
$version = config('constants.coolify.version');
try {
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
} catch (\Throwable $e) {
echo "Error in sending live signal: {$e->getMessage()}\n";
}
// Disabled for MapleDeploy: do not send telemetry to coolify.io
return;
}
private function replaceSlashInEnvironmentName()

View file

@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class SshMultiplexingHelper
{
@ -209,12 +210,37 @@ private static function isMultiplexingEnabled(): bool
private static function validateSshKey(PrivateKey $privateKey): void
{
$keyLocation = $privateKey->getKeyLocation();
$checkKeyCommand = "ls $keyLocation 2>/dev/null";
$keyCheckProcess = Process::run($checkKeyCommand);
$filename = "ssh_key@{$privateKey->uuid}";
$disk = Storage::disk('ssh-keys');
if ($keyCheckProcess->exitCode() !== 0) {
$needsRewrite = false;
if (! $disk->exists($filename)) {
$needsRewrite = true;
} else {
$diskContent = $disk->get($filename);
if ($diskContent !== $privateKey->private_key) {
Log::warning('SSH key file content does not match database, resyncing', [
'key_uuid' => $privateKey->uuid,
]);
$needsRewrite = true;
}
}
if ($needsRewrite) {
$privateKey->storeInFileSystem();
}
// Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
Log::warning('Failed to set SSH key file permissions to 0600', [
'key_uuid' => $privateKey->uuid,
'path' => $keyLocation,
]);
}
}
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string

View file

@ -18,6 +18,7 @@
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
@ -2471,7 +2472,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@ -2482,8 +2483,6 @@ public function update_by_uuid(Request $request)
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
@ -2958,7 +2957,7 @@ public function update_env_by_uuid(Request $request)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3159,7 +3158,7 @@ public function create_bulk_envs(Request $request)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3176,7 +3175,7 @@ public function create_bulk_envs(Request $request)
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']);
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@ -3189,6 +3188,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
@ -3221,6 +3221,9 @@ public function create_bulk_envs(Request $request)
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@ -3232,6 +3235,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@ -3255,6 +3259,9 @@ public function create_bulk_envs(Request $request)
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@ -3266,6 +3273,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@ -3353,7 +3361,7 @@ public function create_env(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3510,7 +3518,7 @@ public function delete_env_by_uuid(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3520,7 +3528,7 @@ public function delete_env_by_uuid(Request $request)
$this->authorize('manageEnvironment', $application);
$found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
$found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->first();
@ -3919,4 +3927,260 @@ private function validateDataApplications(Request $request, Server $server)
}
}
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'list-storages-by-application-uuid',
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',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by application UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
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',
),
]
)]
public function storages(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('view', $application);
$persistentStorages = $application->persistentStorages->sortBy('id')->values();
$fileStorages = $application->fileStorages->sortBy('id')->values();
return response()->json([
'persistent_storages' => $persistentStorages,
'file_storages' => $fileStorages,
]);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'update-storage-by-application-uuid',
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',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['id', 'type'],
properties: [
'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'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,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
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 update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json([
'message' => 'Application not found',
], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'id' => 'required|integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => 'string',
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->type === 'persistent') {
$storage = $application->persistentStorages->where('id', $request->id)->first();
} else {
$storage = $application->fileStorages->where('id', $request->id)->first();
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
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;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
}

View file

@ -11,6 +11,7 @@
use App\Http\Controllers\Controller;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
@ -2750,4 +2751,551 @@ public function action_restart(Request $request)
200
);
}
private function removeSensitiveEnvData($env)
{
$env->makeHidden([
'id',
'resourceable',
'resourceable_id',
'resourceable_type',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$env->makeHidden([
'value',
'real_value',
]);
}
return serializeApiResponse($env);
}
#[OA\Get(
summary: 'List Envs',
description: 'List all envs by database UUID.',
path: '/databases/{uuid}/envs',
operationId: 'list-envs-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Environment variables.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
)
),
]
),
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',
),
]
)]
public function envs(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('view', $database);
$envs = $database->environment_variables->map(function ($env) {
return $this->removeSensitiveEnvData($env);
});
return response()->json($envs);
}
#[OA\Patch(
summary: 'Update Env',
description: 'Update env by database UUID.',
path: '/databases/{uuid}/envs',
operationId: 'update-env-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Env updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['key', 'value'],
properties: [
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
],
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Environment variable updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
ref: '#/components/schemas/EnvironmentVariable'
)
),
]
),
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 update_env_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('manageEnvironment', $database);
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$key = str($request->key)->trim()->replace(' ', '_')->value;
$env = $database->environment_variables()->where('key', $key)->first();
if (! $env) {
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$env->value = $request->value;
if ($request->has('is_literal')) {
$env->is_literal = $request->is_literal;
}
if ($request->has('is_multiline')) {
$env->is_multiline = $request->is_multiline;
}
if ($request->has('is_shown_once')) {
$env->is_shown_once = $request->is_shown_once;
}
if ($request->has('comment')) {
$env->comment = $request->comment;
}
$env->save();
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
#[OA\Patch(
summary: 'Update Envs (Bulk)',
description: 'Update multiple envs by database UUID.',
path: '/databases/{uuid}/envs/bulk',
operationId: 'update-envs-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Bulk envs updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['data'],
properties: [
'data' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
],
),
],
],
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Environment variables updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
)
),
]
),
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 create_bulk_envs(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('manageEnvironment', $database);
$bulk_data = $request->get('data');
if (! $bulk_data) {
return response()->json(['message' => 'Bulk data is required.'], 400);
}
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
$validator = customApiValidator($item, [
'key' => 'string|required',
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$key = str($item['key'])->trim()->replace(' ', '_')->value;
$env = $database->environment_variables()->updateOrCreate(
['key' => $key],
$item
);
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
return response()->json($updatedEnvs)->setStatusCode(201);
}
#[OA\Post(
summary: 'Create Env',
description: 'Create env by database UUID.',
path: '/databases/{uuid}/envs',
operationId: 'create-env-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
required: true,
description: 'Env created.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
],
),
),
),
responses: [
new OA\Response(
response: 201,
description: 'Environment variable created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'example' => 'nc0k04gk8g0cgsk440g0koko'],
]
)
),
]
),
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 create_env(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('manageEnvironment', $database);
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$key = str($request->key)->trim()->replace(' ', '_')->value;
$existingEnv = $database->environment_variables()->where('key', $key)->first();
if ($existingEnv) {
return response()->json([
'message' => 'Environment variable already exists. Use PATCH request to update it.',
], 409);
}
$env = $database->environment_variables()->create([
'key' => $key,
'value' => $request->value,
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
'comment' => $request->comment ?? null,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
#[OA\Delete(
summary: 'Delete Env',
description: 'Delete env by UUID.',
path: '/databases/{uuid}/envs/{env_uuid}',
operationId: 'delete-env-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'env_uuid',
in: 'path',
description: 'UUID of the environment variable.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Environment variable deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variable deleted.'],
]
)
),
]
),
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',
),
]
)]
public function delete_env_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('manageEnvironment', $database);
$env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', get_class($database))
->where('resourceable_id', $database->id)
->first();
if (! $env) {
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$env->forceDelete();
return response()->json(['message' => 'Environment variable deleted.']);
}
}

View file

@ -4,8 +4,9 @@
use OpenApi\Attributes as OA;
#[OA\Info(title: 'Coolify', version: '0.1')]
#[OA\Server(url: 'https://app.coolify.io/api/v1', description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.')]
// MapleDeploy branding: API documentation
#[OA\Info(title: 'MapleDeploy', version: '0.1')]
#[OA\Server(url: '/api/v1', description: 'MapleDeploy API. Powered by Coolify.')]
#[OA\SecurityScheme(
type: 'http',
scheme: 'bearer',

View file

@ -4,7 +4,6 @@
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use OpenApi\Attributes as OA;
class OtherController extends Controller
@ -145,19 +144,6 @@ public function disable_api(Request $request)
return response()->json(['message' => 'API disabled.'], 200);
}
public function feedback(Request $request)
{
$content = $request->input('content');
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) {
Http::post($webhook_url, [
'content' => $content,
]);
}
return response()->json(['message' => 'Feedback sent.'], 200);
}
#[OA\Get(
summary: 'Healthcheck',
description: 'Healthcheck endpoint.',

View file

@ -7,6 +7,7 @@
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\PrivateKey;
use App\Models\Project;
@ -758,12 +759,22 @@ public function delete_server(Request $request)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if ($server->definedResources()->count() > 0) {
return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
if ($server->definedResources()->count() > 0 && ! $force) {
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->isLocalhost()) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
if ($force) {
foreach ($server->definedResources() as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$server->delete();
DeleteServer::dispatch(
$server->id,

View file

@ -222,6 +222,7 @@ public function services(Request $request)
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
),
),
@ -288,7 +289,7 @@ public function services(Request $request)
)]
public function create_service(Request $request)
{
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override'];
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -317,6 +318,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@ -429,6 +431,9 @@ public function create_service(Request $request)
$service = Service::create($servicePayload);
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@ -485,7 +490,7 @@ public function create_service(Request $request)
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'project_uuid' => 'string|required',
@ -503,6 +508,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@ -609,6 +615,9 @@ public function create_service(Request $request)
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
$service->parse(isNew: true);
@ -835,6 +844,7 @@ public function delete_by_uuid(Request $request)
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@ -923,7 +933,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $service);
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@ -936,6 +946,7 @@ public function update_by_uuid(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@ -1001,6 +1012,9 @@ public function update_by_uuid(Request $request)
if ($request->has('connect_to_docker_network')) {
$service->connect_to_docker_network = $request->connect_to_docker_network;
}
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
$service->parse();
@ -1193,7 +1207,7 @@ public function update_env_by_uuid(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@ -1328,7 +1342,7 @@ public function create_bulk_envs(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@ -1348,6 +1362,7 @@ public function create_bulk_envs(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@ -1447,7 +1462,7 @@ public function create_env(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@ -1556,14 +1571,14 @@ public function delete_env_by_uuid(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('manageEnvironment', $service);
$env = EnvironmentVariable::where('uuid', $request->env_uuid)
$env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Service::class)
->where('resourceable_id', $service->id)
->first();

View file

@ -223,7 +223,11 @@ public function __construct(public int $application_deployment_queue_id)
$this->preserveRepository = $this->application->settings->is_preserve_repository_enabled;
$this->basedir = $this->application->generateBaseDir($this->deployment_uuid);
$this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/');
$baseDir = $this->application->base_directory;
if ($baseDir && $baseDir !== '/') {
$this->validatePathField($baseDir, 'base_directory');
}
$this->workdir = "{$this->basedir}".rtrim($baseDir, '/');
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
@ -312,7 +316,11 @@ public function handle(): void
}
if ($this->application->dockerfile_target_build) {
$this->buildTarget = " --target {$this->application->dockerfile_target_build} ";
$target = $this->application->dockerfile_target_build;
if (! preg_match(\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
}
$this->buildTarget = " --target {$target} ";
}
// Check custom port
@ -571,12 +579,15 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command');
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
$this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
$projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir;
$this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
}
if (data_get($this->application, 'docker_compose_custom_build_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command');
$this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command;
if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) {
$this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
@ -1102,10 +1113,21 @@ private function generate_image_names()
private function just_restart()
{
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
// Restart doesn't need the build server — disable it so the helper container
// is created on the deployment server with the correct network/flags.
$originalUseBuildServer = $this->use_build_server;
$this->use_build_server = false;
$this->prepare_builder_image();
$this->check_git_if_build_needed();
$this->generate_image_names();
$this->check_image_locally_or_remotely();
// Restore before should_skip_build() — it may re-enter decide_what_to_do()
// for a full rebuild which needs the build server.
$this->use_build_server = $originalUseBuildServer;
$this->should_skip_build();
$this->completeDeployment();
}
@ -2744,7 +2766,8 @@ private function generate_local_persistent_volumes()
} else {
$volume_name = $persistentStorage->name;
}
if ($this->pull_request_id !== 0) {
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
}
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
@ -2762,7 +2785,8 @@ private function generate_local_persistent_volumes_only_volume_names()
}
$name = $persistentStorage->name;
if ($this->pull_request_id !== 0) {
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
}
@ -2780,9 +2804,15 @@ private function generate_healthcheck_commands()
// Handle CMD type healthcheck
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
$this->full_healthcheck_url = $command;
return $command;
// Defense in depth: validate command at runtime (matches input validation regex)
if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) {
$this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.');
} else {
$this->full_healthcheck_url = $command;
return $command;
}
}
// HTTP type healthcheck (default)
@ -2803,16 +2833,16 @@ private function generate_healthcheck_commands()
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
$method = escapeshellarg($method);
$escapedMethod = escapeshellarg($method);
if ($path) {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}";
} else {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/";
}
$generated_healthchecks_commands = [
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
"curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
];
return implode(' ', $generated_healthchecks_commands);
@ -3939,6 +3969,24 @@ private function validatePathField(string $value, string $fieldName): string
return $value;
}
private function validateShellSafeCommand(string $value, string $fieldName): string
{
if (! preg_match(\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters.");
}
return $value;
}
private function validateContainerName(string $value): string
{
if (! preg_match(\App\Support\ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
throw new \RuntimeException('Invalid container name: contains forbidden characters.');
}
return $value;
}
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
@ -3952,7 +4000,17 @@ private function run_pre_deployment_command()
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
// Security: pre_deployment_command is intentionally treated as arbitrary shell input.
// Users (team members with deployment access) need full shell flexibility to run commands
// like "php artisan migrate", "npm run build", etc. inside their own application containers.
// The trust boundary is at the application/team ownership level — only authenticated team
// members can set these commands, and execution is scoped to the application's own container.
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
// restrict the command itself. Container names are validated separately via validateContainerName().
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
@ -3979,7 +4037,12 @@ private function run_post_deployment_command()
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
try {

View file

@ -625,10 +625,16 @@ private function calculate_size()
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
$this->backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
}
try {
if (is_null($this->s3)) {
return;
}
$key = $this->s3->key;
$secret = $this->s3->secret;
// $region = $this->s3->region;

View file

@ -39,7 +39,9 @@ public function __construct(
public bool $manualCleanup = false,
public bool $deleteUnusedVolumes = false,
public bool $deleteUnusedNetworks = false
) {}
) {
$this->onQueue('high');
}
public function handle(): void
{

View file

@ -0,0 +1,55 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Notify MapleDeploy that the first user has registered on this Coolify instance.
*
* Sends the setup token as a Bearer token so MapleDeploy can verify authenticity
* and clear its stored copy. The token acts as a one-time shared secret.
*/
class NotifySetupCompleteJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 5;
public array $backoff = [10, 30, 60, 120, 300];
public int $maxExceptions = 5;
public function __construct(
public string $setupToken,
public string $callbackUrl
) {
$this->onQueue('high');
}
public function handle(): void
{
$response = Http::withToken($this->setupToken)
->timeout(15)
->post($this->callbackUrl);
if (! $response->successful()) {
Log::warning('Setup-complete callback failed', [
'status' => $response->status(),
'url' => $this->callbackUrl,
]);
// Throw so the job retries
throw new \RuntimeException(
"Setup-complete callback returned HTTP {$response->status()}"
);
}
}
}

View file

@ -350,7 +350,7 @@ private function shouldRunNow(string $frequency, string $timezone, ?string $dedu
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
// No dedup key → simple isDue check (used by docker cleanups)
// No dedup key → simple isDue check
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
@ -411,7 +411,7 @@ private function processDockerCleanups(): void
}
// Use the frozen execution time for consistent evaluation
if ($this->shouldRunNow($frequency, $serverTimezone)) {
if ($this->shouldRunNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}")) {
DockerCleanupJob::dispatch(
$server,
false,

View file

@ -108,10 +108,6 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerConnectionCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
@ -131,11 +127,8 @@ private function checkHetznerStatus(): void
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Hetzner status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
} catch (\Throwable) {
// Silently ignore — server may have been deleted from Hetzner.
}
if ($this->server->hetzner_server_status !== $status) {
$this->server->update(['hetzner_server_status' => $status]);

View file

@ -14,6 +14,7 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
@ -80,7 +81,7 @@ private function getServers(): Collection
private function dispatchConnectionChecks(Collection $servers): void
{
if ($this->shouldRunNow($this->checkFrequency)) {
if ($this->shouldRunNow($this->checkFrequency, dedupKey: 'server-connection-checks')) {
$servers->each(function (Server $server) {
try {
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
@ -129,13 +130,13 @@ private function processServerTasks(Server $server): void
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}")) {
ServerCheckJob::dispatch($server);
}
}
$isSentinelEnabled = $server->isSentinelEnabled();
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}");
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
@ -149,7 +150,7 @@ private function processServerTasks(Server $server): void
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone);
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}");
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server);
@ -157,7 +158,7 @@ private function processServerTasks(Server $server): void
}
// Dispatch ServerPatchCheckJob if due (weekly)
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}");
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
@ -167,7 +168,14 @@ private function processServerTasks(Server $server): void
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
private function shouldRunNow(string $frequency, ?string $timezone = null): bool
/**
* Determine if a cron schedule should run now.
*
* When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
* instead of isDue(). This is resilient to queue delays even if the job is delayed
* by minutes, it still catches the missed cron window.
*/
private function shouldRunNow(string $frequency, ?string $timezone = null, ?string $dedupKey = null): bool
{
$cron = new CronExpression($frequency);
@ -175,6 +183,29 @@ private function shouldRunNow(string $frequency, ?string $timezone = null): bool
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
return $cron->isDue($executionTime);
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
if ($lastDispatched === null) {
$isDue = $cron->isDue($executionTime);
if ($isDue) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
}
return $isDue;
}
if ($previousDue->gt(Carbon::parse($lastDispatched))) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
return true;
}
return false;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -238,6 +239,7 @@ public function handle(): void
'stripe_invoice_paid' => false,
]);
}
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
$userId = data_get($data, 'metadata.user_id');
@ -272,14 +274,14 @@ public function handle(): void
$comment = data_get($data, 'cancellation_details.comment');
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
if (str($lookup_key)->contains('dynamic')) {
$quantity = data_get($data, 'items.data.0.quantity', 2);
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
'custom_server_limit' => $quantity,
]);
ServerLimitCheckJob::dispatch($team);
}
ServerLimitCheckJob::dispatch($team);
}
$subscription->update([
'stripe_feedback' => $feedback,

View file

@ -179,6 +179,9 @@ public function handle(): void
// Mark validation as complete
$this->server->update(['is_validating' => false]);
// Auto-fetch server details now that validation passed
$this->server->gatherServerMetadata();
// Refresh server to get latest state
$this->server->refresh();

View file

@ -82,12 +82,9 @@ public function handle(): void
'stripe_past_due' => false,
]);
// Trigger subscription ended logic if canceled
if ($stripeSubscription->status === 'canceled') {
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
break;

View file

@ -1,58 +0,0 @@
<?php
namespace App\Livewire;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Help extends Component
{
use WithRateLimiting;
#[Validate(['required', 'min:10', 'max:1000'])]
public string $description;
#[Validate(['required', 'min:3'])]
public string $subject;
public function submit()
{
try {
$this->validate();
$this->rateLimit(3, 30);
$settings = instanceSettings();
$mail = new MailMessage;
$mail->view(
'emails.help',
[
'description' => $this->description,
]
);
$mail->subject("[HELP]: {$this->subject}");
$type = set_transanctional_email_settings($settings);
// Sending feedback through Cloud API
if (blank($type)) {
$url = 'https://app.coolify.io/api/feedback';
Http::post($url, [
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
]);
} else {
send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
}
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
$this->reset('description', 'subject');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.help')->layout('layouts.app');
}
}

View file

@ -7,7 +7,6 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@ -22,136 +21,95 @@ class General extends Component
public Collection $services;
#[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['required'])]
public string $gitRepository;
#[Validate(['required'])]
public string $gitBranch;
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
public ?string $gitCommitSha = null;
#[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $buildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $startCommand = null;
#[Validate(['required'])]
public string $buildPack;
#[Validate(['required'])]
public string $staticImage;
#[Validate(['required'])]
public string $baseDirectory;
#[Validate(['string', 'nullable'])]
public ?string $publishDirectory = null;
#[Validate(['string', 'nullable'])]
public ?string $portsExposes = null;
#[Validate(['string', 'nullable'])]
public ?string $portsMappings = null;
#[Validate(['string', 'nullable'])]
public ?string $customNetworkAliases = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfileTargetBuild = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageName = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerCompose = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeRaw = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomStartCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomBuildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $customDockerRunOptions = null;
#[Validate(['string', 'nullable'])]
// Security: pre/post deployment commands are intentionally arbitrary shell — users need full
// flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization.
// Commands execute inside the application's own container, not on the host.
public ?string $preDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $customNginxConfiguration = null;
#[Validate(['boolean', 'required'])]
public bool $isStatic = false;
#[Validate(['boolean', 'required'])]
public bool $isSpa = false;
#[Validate(['boolean', 'required'])]
public bool $isBuildServerEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isPreserveRepositoryEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelEscapeEnabled = true;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelReadonlyEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isHttpBasicAuthEnabled = false;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthUsername = null;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthPassword = null;
#[Validate(['nullable'])]
public ?string $watchPaths = null;
#[Validate(['string', 'required'])]
public string $redirect;
#[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
@ -184,33 +142,33 @@ protected function rules(): array
'fqdn' => 'nullable',
'gitRepository' => 'required',
'gitBranch' => 'required',
'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => 'nullable',
'buildCommand' => 'nullable',
'startCommand' => 'nullable',
'buildPack' => 'required',
'staticImage' => 'required',
'baseDirectory' => 'required',
'publishDirectory' => 'nullable',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => 'required',
'portsMappings' => 'nullable',
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable',
'dockerRegistryImageTag' => 'nullable',
'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable',
'dockerComposeRaw' => 'nullable',
'dockerfileTargetBuild' => 'nullable',
'dockerComposeCustomStartCommand' => 'nullable',
'dockerComposeCustomBuildCommand' => 'nullable',
'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(),
'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(),
'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(),
'customLabels' => 'nullable',
'customDockerRunOptions' => 'nullable',
'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000),
'preDeploymentCommand' => 'nullable',
'preDeploymentCommandContainer' => 'nullable',
'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'postDeploymentCommand' => 'nullable',
'postDeploymentCommandContainer' => 'nullable',
'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'customNginxConfiguration' => 'nullable',
'isStatic' => 'boolean|required',
'isSpa' => 'boolean|required',
@ -233,6 +191,14 @@ protected function messages(): array
[
...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'),
...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'),
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',

View file

@ -13,33 +13,33 @@ class Index extends Component
public Environment $environment;
public Collection $applications;
public Collection $postgresqls;
public Collection $redis;
public Collection $mongodbs;
public Collection $mysqls;
public Collection $mariadbs;
public Collection $keydbs;
public Collection $dragonflies;
public Collection $clickhouses;
public Collection $services;
public Collection $allProjects;
public Collection $allEnvironments;
public array $parameters;
public function mount()
protected Collection $applications;
protected Collection $postgresqls;
protected Collection $redis;
protected Collection $mongodbs;
protected Collection $mysqls;
protected Collection $mariadbs;
protected Collection $keydbs;
protected Collection $dragonflies;
protected Collection $clickhouses;
protected Collection $services;
public function mount(): void
{
$this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect();
$this->parameters = get_route_parameters();
@ -55,31 +55,23 @@ public function mount()
$this->project = $project;
// Load projects and environments for breadcrumb navigation (avoids inline queries in view)
// Load projects and environments for breadcrumb navigation
$this->allProjects = Project::ownedByCurrentTeamCached();
$this->allEnvironments = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->with([
'applications.additional_servers',
'applications.destination.server',
'services',
'services.destination.server',
'postgresqls',
'postgresqls.destination.server',
'redis',
'redis.destination.server',
'mongodbs',
'mongodbs.destination.server',
'mysqls',
'mysqls.destination.server',
'mariadbs',
'mariadbs.destination.server',
'keydbs',
'keydbs.destination.server',
'dragonflies',
'dragonflies.destination.server',
'clickhouses',
'clickhouses.destination.server',
])->get();
'applications:id,uuid,name,environment_id',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
'mongodbs:id,uuid,name,environment_id',
'mysqls:id,uuid,name,environment_id',
'mariadbs:id,uuid,name,environment_id',
'keydbs:id,uuid,name,environment_id',
'dragonflies:id,uuid,name,environment_id',
'clickhouses:id,uuid,name,environment_id',
])
->get();
$this->environment = $environment->loadCount([
'applications',
@ -94,11 +86,9 @@ public function mount()
'services',
]);
// Eager load all relationships for applications including nested ones
// Eager load relationships for applications
$this->applications = $this->environment->applications()->with([
'tags',
'additional_servers.settings',
'additional_networks',
'destination.server.settings',
'settings',
])->get()->sortBy('name');
@ -160,6 +150,49 @@ public function mount()
public function render()
{
return view('livewire.project.resource.index');
return view('livewire.project.resource.index', [
'applications' => $this->applications,
'postgresqls' => $this->postgresqls,
'redis' => $this->redis,
'mongodbs' => $this->mongodbs,
'mysqls' => $this->mysqls,
'mariadbs' => $this->mariadbs,
'keydbs' => $this->keydbs,
'dragonflies' => $this->dragonflies,
'clickhouses' => $this->clickhouses,
'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
'redisJs' => $this->toSearchableArray($this->redis),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
'keydbsJs' => $this->toSearchableArray($this->keydbs),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
'servicesJs' => $this->toSearchableArray($this->services),
]);
}
private function toSearchableArray(Collection $items): array
{
return $items->map(fn ($item) => [
'uuid' => $item->uuid,
'name' => $item->name,
'fqdn' => $item->fqdn ?? null,
'description' => $item->description ?? null,
'status' => $item->status ?? '',
'server_status' => $item->server_status ?? null,
'hrefLink' => $item->hrefLink ?? '',
'destination' => [
'server' => [
'name' => $item->destination?->server?->name ?? 'Unknown',
],
],
'tags' => $item->tags->map(fn ($tag) => [
'id' => $tag->id,
'name' => $tag->name,
])->values()->toArray(),
])->values()->toArray();
}
}

View file

@ -40,12 +40,16 @@ class FileStorage extends Component
#[Validate(['required', 'boolean'])]
public bool $isBasedOnGit = false;
#[Validate(['required', 'boolean'])]
public bool $isPreviewSuffixEnabled = true;
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
'fileStorage.mount_path' => 'required',
'content' => 'nullable',
'isBasedOnGit' => 'required|boolean',
'isPreviewSuffixEnabled' => 'required|boolean',
];
public function mount()
@ -71,12 +75,14 @@ public function syncData(bool $toModel = false): void
// Sync to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
$this->fileStorage->save();
} else {
// Sync from model
$this->content = $this->fileStorage->content;
$this->isBasedOnGit = $this->fileStorage->is_based_on_git;
$this->isPreviewSuffixEnabled = $this->fileStorage->is_preview_suffix_enabled ?? true;
}
}
@ -175,6 +181,7 @@ public function submit()
// Sync component properties to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
@ -187,9 +194,11 @@ public function submit()
}
}
public function instantSave()
public function instantSave(): void
{
$this->submit();
$this->authorize('update', $this->resource);
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}
public function render()

View file

@ -29,10 +29,13 @@ class Show extends Component
public ?string $hostPath = null;
public bool $isPreviewSuffixEnabled = true;
protected $rules = [
'name' => 'required|string',
'mountPath' => 'required|string',
'hostPath' => 'string|nullable',
'isPreviewSuffixEnabled' => 'required|boolean',
];
protected $validationAttributes = [
@ -53,11 +56,13 @@ private function syncData(bool $toModel = false): void
$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;
}
}
@ -67,6 +72,16 @@ public function mount()
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
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);

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
use App\Jobs\DeleteResourceJob;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -15,6 +16,8 @@ class Delete extends Component
public bool $delete_from_hetzner = false;
public bool $force_delete_resources = false;
public function mount(string $server_uuid)
{
try {
@ -32,15 +35,22 @@ public function delete($password, $selectedActions = [])
if (! empty($selectedActions)) {
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
try {
$this->authorize('delete', $this->server);
if ($this->server->hasDefinedResources()) {
$this->dispatch('error', 'Server has defined resources. Please delete them first.');
if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
$this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
return;
}
if ($this->force_delete_resources) {
foreach ($this->server->definedResources() as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$this->server->delete();
DeleteServer::dispatch(
$this->server->id,
@ -60,6 +70,15 @@ public function render()
{
$checkboxes = [];
if ($this->server->hasDefinedResources()) {
$resourceCount = $this->server->definedResources()->count();
$checkboxes[] = [
'id' => 'force_delete_resources',
'label' => "Delete all resources ({$resourceCount} total)",
'default_warning' => 'Server cannot be deleted while it has resources.',
];
}
if ($this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',

View file

@ -198,6 +198,9 @@ public function validateDockerVersion()
// Mark validation as complete
$this->server->update(['is_validating' => false]);
// Auto-fetch server details now that validation passed
$this->server->gatherServerMetadata();
$this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex');
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);

View file

@ -28,8 +28,7 @@ class Advanced extends Component
public ?string $allowed_ips = null;
#[Validate('boolean')]
public bool $is_sponsorship_popup_enabled;
// MapleDeploy branding: is_sponsorship_popup_enabled removed (popup removed)
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
@ -46,7 +45,6 @@ public function rules()
'custom_dns_servers' => 'nullable|string',
'is_api_enabled' => 'boolean',
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
];
@ -65,7 +63,6 @@ public function mount()
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
}
@ -147,7 +144,6 @@ public function instantSave()
$this->settings->custom_dns_servers = $this->custom_dns_servers;
$this->settings->is_api_enabled = $this->is_api_enabled;
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->save();

View file

@ -239,7 +239,7 @@ public function mount()
if (isCloud() && ! isDev()) {
$this->webhook_endpoint = config('app.url');
} else {
$this->webhook_endpoint = $this->ipv4 ?? '';
$this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? '';
$this->is_system_wide = $this->github_app->is_system_wide;
}
} catch (\Throwable $e) {

View file

@ -6,6 +6,7 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
class Form extends Component
@ -131,19 +132,7 @@ public function testConnection()
}
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
#[On('submitStorage')]
public function submit()
{
try {

View file

@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Livewire\Component;
class Resources extends Component
{
public S3Storage $storage;
public array $selectedStorages = [];
public function mount(): void
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->get();
foreach ($backups as $backup) {
$this->selectedStorages[$backup->id] = $this->storage->id;
}
}
public function disableS3(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
}
public function moveBackup(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$newStorageId = $this->selectedStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
$this->dispatch('error', 'No change.', 'The backup is already using this storage.');
return;
}
$newStorage = S3Storage::where('id', $newStorageId)
->where('team_id', $this->storage->team_id)
->first();
if (! $newStorage) {
$this->dispatch('error', 'Storage not found.');
return;
}
$backup->update(['s3_storage_id' => $newStorage->id]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
}
public function render()
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->with('database')
->get()
->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id);
$allStorages = S3Storage::where('team_id', $this->storage->team_id)
->orderBy('name')
->get(['id', 'name', 'is_usable']);
return view('livewire.storage.resources', [
'groupedBackups' => $backups,
'allStorages' => $allStorages,
]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -12,6 +13,10 @@ class Show extends Component
public $storage = null;
public string $currentRoute = '';
public int $backupCount = 0;
public function mount()
{
$this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first();
@ -19,6 +24,21 @@ public function mount()
abort(404);
}
$this->authorize('view', $this->storage);
$this->currentRoute = request()->route()->getName();
$this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count();
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()

View file

@ -7,6 +7,7 @@
use App\Actions\Stripe\ResumeSubscription;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Team;
use Carbon\Carbon;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Stripe\StripeClient;
@ -31,10 +32,15 @@ class Actions extends Component
public bool $refundAlreadyUsed = false;
public string $billingInterval = 'monthly';
public ?string $nextBillingDate = null;
public function mount(): void
{
$this->server_limits = Team::serverLimit();
$this->quantity = (int) $this->server_limits;
$this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly';
}
public function loadPricePreview(int $quantity): void
@ -198,6 +204,10 @@ private function checkRefundEligibility(): void
$result = (new RefundSubscription)->checkEligibility(currentTeam());
$this->isRefundEligible = $result['eligible'];
$this->refundDaysRemaining = $result['days_remaining'];
if ($result['current_period_end']) {
$this->nextBillingDate = Carbon::createFromTimestamp($result['current_period_end'])->format('M j, Y');
}
} catch (\Exception $e) {
\Log::warning('Refund eligibility check failed: '.$e->getMessage());
}

View file

@ -1732,7 +1732,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->save();
if (str($e->getMessage())->contains('No such file')) {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) {
if ($this->deploymentType() === 'deploy_key') {
@ -1793,7 +1793,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->base_directory = $initialBaseDirectory;
$this->save();
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}

View file

@ -166,6 +166,16 @@ public function generate_preview_fqdn_compose()
}
$this->docker_compose_domains = json_encode($docker_compose_domains);
// Populate fqdn from generated domains so webhook notifications can read it
$allDomains = collect($docker_compose_domains)
->pluck('domain')
->filter(fn ($d) => ! empty($d))
->flatMap(fn ($d) => explode(',', $d))
->implode(',');
$this->fqdn = ! empty($allDomains) ? $allDomains : null;
$this->save();
}
}

View file

@ -14,6 +14,7 @@ class LocalFileVolume extends BaseModel
// 'mount_path' => 'encrypted',
'content' => 'encrypted',
'is_directory' => 'boolean',
'is_preview_suffix_enabled' => 'boolean',
];
use HasFactory;

View file

@ -10,6 +10,10 @@ class LocalPersistentVolume extends Model
{
protected $guarded = [];
protected $casts = [
'is_preview_suffix_enabled' => 'boolean',
];
public function resource()
{
return $this->morphTo('resource');

View file

@ -5,6 +5,7 @@
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
@ -65,6 +66,20 @@ protected static function booted()
}
});
static::saved(function ($key) {
if ($key->wasChanged('private_key')) {
try {
$key->storeInFileSystem();
refresh_server_connection($key);
} catch (\Exception $e) {
Log::error('Failed to resync SSH key after update', [
'key_uuid' => $key->uuid,
'error' => $e->getMessage(),
]);
}
}
});
static::deleted(function ($key) {
self::deleteFromStorage($key);
});
@ -185,29 +200,54 @@ public function storeInFileSystem()
{
$filename = "ssh_key@{$this->uuid}";
$disk = Storage::disk('ssh-keys');
$keyLocation = $this->getKeyLocation();
$lockFile = $keyLocation.'.lock';
// Ensure the storage directory exists and is writable
$this->ensureStorageDirectoryExists();
// Attempt to store the private key
$success = $disk->put($filename, $this->private_key);
if (! $success) {
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
// Use file locking to prevent concurrent writes from corrupting the key
$lockHandle = fopen($lockFile, 'c');
if ($lockHandle === false) {
throw new \Exception("Failed to open lock file for SSH key: {$lockFile}");
}
// Verify the file was actually created and has content
if (! $disk->exists($filename)) {
throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
}
try {
if (! flock($lockHandle, LOCK_EX)) {
throw new \Exception("Failed to acquire lock for SSH key: {$keyLocation}");
}
$storedContent = $disk->get($filename);
if (empty($storedContent) || $storedContent !== $this->private_key) {
$disk->delete($filename); // Clean up the bad file
throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
}
// Attempt to store the private key
$success = $disk->put($filename, $this->private_key);
return $this->getKeyLocation();
if (! $success) {
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$keyLocation}");
}
// Verify the file was actually created and has content
if (! $disk->exists($filename)) {
throw new \Exception("SSH key file was not created: {$keyLocation}");
}
$storedContent = $disk->get($filename);
if (empty($storedContent) || $storedContent !== $this->private_key) {
$disk->delete($filename); // Clean up the bad file
throw new \Exception("SSH key file content verification failed: {$keyLocation}");
}
// Ensure correct permissions for SSH (0600 required)
if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) {
Log::warning('Failed to set SSH key file permissions to 0600', [
'key_uuid' => $this->uuid,
'path' => $keyLocation,
]);
}
return $keyLocation;
} finally {
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
}
public static function deleteFromStorage(self $privateKey)
@ -254,12 +294,6 @@ public function updatePrivateKey(array $data)
return DB::transaction(function () use ($data) {
$this->update($data);
try {
$this->storeInFileSystem();
} catch (\Exception $e) {
throw new \Exception('Failed to update SSH key: '.$e->getMessage());
}
return $this;
});
}

View file

@ -40,6 +40,13 @@ protected static function boot(): void
$storage->secret = trim($storage->secret);
}
});
static::deleting(function (S3Storage $storage) {
ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
'save_s3' => false,
's3_storage_id' => null,
]);
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
@ -59,6 +66,11 @@ public function team()
return $this->belongsTo(Team::class);
}
public function scheduledBackups()
{
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
}
public function awsUrl()
{
return "{$this->endpoint}/{$this->bucket}";

View file

@ -135,7 +135,7 @@ protected static function booted()
$server->forceFill($payload);
});
static::saved(function ($server) {
if ($server->privateKey?->isDirty()) {
if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
refresh_server_connection($server->privateKey);
}
});

View file

@ -197,6 +197,10 @@ public function isAnyNotificationEnabled()
public function subscriptionEnded()
{
if (! $this->subscription) {
return;
}
$this->subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,

View file

@ -81,7 +81,7 @@ protected static function boot()
$team = [
'name' => $user->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($user->id === 0) {
$team['id'] = 0;
@ -184,7 +184,7 @@ public function recreate_personal_team()
$team = [
'name' => $this->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($this->id === 0) {
$team['id'] = 0;

View file

@ -44,15 +44,24 @@ public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::registerView(function () {
$isFirstUser = User::count() === 0;
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
return redirect()->route('login');
}
$isFirstUser = User::count() === 0;
// MapleDeploy: token-gated registration for first user
if ($isFirstUser && $settings->setup_token) {
$token = request()->query('setup_token');
if (! $token || ! hash_equals($settings->setup_token, $token)) {
abort(403);
}
}
return view('auth.register', [
'isFirstUser' => $isFirstUser,
'setupToken' => request()->query('setup_token'),
]);
});
@ -61,7 +70,15 @@ public function boot(): void
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
if ($users == 0) {
// If there are no users, redirect to registration
// MapleDeploy: don't redirect to register if setup token is required
if ($settings->setup_token) {
return view('auth.login', [
'setup_pending' => true,
'is_registration_enabled' => false,
'enabled_oauth_providers' => collect(),
]);
}
return redirect()->route('register');
}

View file

@ -54,13 +54,6 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containerStatuses->count(),
]);
}
if ($containerStatuses->isEmpty()) {
return 'exited';
}
@ -138,13 +131,6 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containers->count(),
]);
}
if ($containers->isEmpty()) {
return 'exited';
}

View file

@ -9,7 +9,7 @@ class ValidationPatterns
{
/**
* Pattern for names excluding all dangerous characters
*/
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
/**
@ -23,6 +23,32 @@ class ValidationPatterns
*/
public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/';
/**
* Pattern for directory paths (base_directory, publish_directory, etc.)
* Like FILE_PATH_PATTERN but also allows bare "/" (root directory)
*/
public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/';
/**
* Pattern for Docker build target names (multi-stage build stage names)
* Allows alphanumeric, dots, hyphens, and underscores
*/
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for shell-safe command strings (docker compose commands, docker run options)
* Blocks dangerous shell metacharacters: ; & | ` $ ( ) > < newlines and carriage returns
* Also blocks backslashes, single quotes, and double quotes to prevent escape-sequence attacks
* Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~]+$/';
/**
* Pattern for Docker container names
* Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
*/
public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Get validation rules for name fields
*/
@ -70,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength =
public static function nameMessages(): array
{
return [
'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &",
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &',
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
@ -105,6 +131,38 @@ public static function filePathMessages(string $field = 'dockerfileLocation', st
];
}
/**
* Get validation rules for directory path fields (base_directory, publish_directory)
*/
public static function directoryPathRules(int $maxLength = 255): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN];
}
/**
* Get validation rules for Docker build target fields
*/
public static function dockerTargetRules(int $maxLength = 128): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN];
}
/**
* Get validation rules for shell-safe command fields
*/
public static function shellSafeCommandRules(int $maxLength = 1000): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
/**
* Get validation rules for container name fields
*/
public static function containerNameRules(int $maxLength = 255): array
{
return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
}
/**
* Get combined validation messages for both name and description fields
*/

View file

@ -3,6 +3,7 @@
namespace App\Traits;
use App\Enums\ApplicationDeploymentStatus;
use App\Exceptions\DeploymentException;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use Carbon\Carbon;
@ -103,7 +104,7 @@ public function execute_remote_command(...$commands)
try {
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
$commandExecuted = true;
} catch (\RuntimeException $e) {
} catch (\RuntimeException|DeploymentException $e) {
$lastError = $e;
$errorMessage = $e->getMessage();
// Only retry if it's an SSH connection error and we haven't exhausted retries
@ -233,7 +234,7 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$error = $process_result->output() ?: 'Command failed with no error output';
}
$redactedCommand = $this->redact_sensitive_info($command);
throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
throw new DeploymentException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
}
}
}

View file

@ -101,8 +101,8 @@ function sharedDataApplications()
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
@ -125,21 +125,24 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
'custom_docker_run_options' => 'string|nullable',
'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
'post_deployment_command_container' => 'string',
'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => 'string',
'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => 'string|nullable',
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
];
}

View file

@ -789,7 +789,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = replaceLocalSource($source, $mainDirectory);
if ($isPullRequest) {
$isPreviewSuffixEnabled = $foundConfig
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
: true;
if ($isPullRequest && $isPreviewSuffixEnabled) {
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
LocalFileVolume::updateOrCreate(
@ -1315,19 +1318,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
$uuid = $resource->uuid;
$network = data_get($resource, 'destination.network');
$labelUuid = $resource->uuid;
$labelNetwork = data_get($resource, 'destination.network');
if ($isPullRequest) {
$uuid = "{$resource->uuid}-{$pullRequestId}";
$labelUuid = "{$resource->uuid}-{$pullRequestId}";
}
if ($isPullRequest) {
$network = "{$resource->destination->network}-{$pullRequestId}";
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@ -1339,8 +1342,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
break;
case ProxyTypes::CADDY->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@ -1354,7 +1357,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
} else {
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@ -1364,8 +1367,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
image: $image
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,

View file

@ -17,7 +17,7 @@
|
*/
'name' => env('APP_NAME', 'Coolify'),
'name' => env('APP_NAME', 'MapleDeploy'), // MapleDeploy branding
/*
|--------------------------------------------------------------------------

View file

@ -1,26 +1,27 @@
<?php
return [
// MapleDeploy branding: registry pointed to Forgejo, auto-update disabled by default
'coolify' => [
'version' => '4.0.0-beta.468',
'version' => '4.0.0-beta.469',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'autoupdate' => env('AUTOUPDATE', false),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
'registry_url' => env('REGISTRY_URL', 'ghcr.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
'registry_url' => env('REGISTRY_URL', 'forgejo.mapledeploy.ca'),
'helper_image' => env('HELPER_IMAGE', 'ghcr.io/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', 'ghcr.io/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'cdn_url' => env('CDN_URL', 'https://updates.mapledeploy.ca'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://updates.mapledeploy.ca').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://updates.mapledeploy.ca').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
],
'urls' => [
'docs' => 'https://coolify.io/docs',
'contact' => 'https://coolify.io/docs/contact',
'docs' => 'https://mapledeploy.ca/docs',
'contact' => 'https://mapledeploy.ca/contact',
],
'services' => [
@ -85,12 +86,12 @@
'verification_code_expiry_minutes' => 10,
],
// MapleDeploy branding: telemetry disabled
'sentry' => [
'sentry_dsn' => env('SENTRY_DSN'),
'sentry_dsn' => null,
],
'webhooks' => [
'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'),
'dev_webhook' => env('SERVEO_URL'),
],

View file

@ -118,7 +118,7 @@
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#ffff00',
'progress_bar_color' => '#fde047',
],
/*

View file

@ -13,7 +13,7 @@ public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_dns_validation_enabled')->default(true);
$table->string('custom_dns_servers')->nullable()->default('1.1.1.1');
$table->string('custom_dns_servers')->nullable()->default('149.112.121.10,149.112.122.10');
});
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('setup_token')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('setup_token');
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('setup_callback_url')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('setup_callback_url');
});
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('local_file_volumes', function (Blueprint $table) {
$table->boolean('is_preview_suffix_enabled')->default(true)->after('is_based_on_git');
});
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->boolean('is_preview_suffix_enabled')->default(true)->after('host_path');
});
}
public function down(): void
{
Schema::table('local_file_volumes', function (Blueprint $table) {
$table->dropColumn('is_preview_suffix_enabled');
});
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->dropColumn('is_preview_suffix_enabled');
});
}
};

View file

@ -1,6 +1,6 @@
services:
coolify:
image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
image: "${REGISTRY_URL:-forgejo.mapledeploy.ca}/rosslh/coolify:${LATEST_IMAGE:-latest}"
volumes:
- type: bind
source: /data/coolify/source/.env
@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.11'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -116,6 +116,16 @@ COPY --chown=www-data:www-data composer.json composer.lock ./
COPY --chown=www-data:www-data app ./app
COPY --chown=www-data:www-data bootstrap ./bootstrap
COPY --chown=www-data:www-data config ./config
# MapleDeploy: inject build version into constants.php at build time.
# The version in git stays at the upstream value to avoid rebase conflicts.
# CI passes the timestamped version (e.g. 4.0.0-beta.468.202603140006) as a build arg.
ARG MAPLEDEPLOY_VERSION=""
RUN if [ -n "$MAPLEDEPLOY_VERSION" ]; then \
sed -i "s/'version' => '[^']*'/'version' => '$MAPLEDEPLOY_VERSION'/" config/constants.php && \
chown www-data:www-data config/constants.php; \
fi
COPY --chown=www-data:www-data database ./database
COPY --chown=www-data:www-data lang ./lang
COPY --chown=www-data:www-data public ./public

View file

@ -1,6 +1,13 @@
{
"scripts": {
"setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json",
"teardown": null,
"run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
}
}
},
"ports": [
{
"port": 8000,
"label": "Coolify UI"
}
]
}

View file

@ -3442,6 +3442,167 @@
]
}
},
"\/applications\/{uuid}\/storages": {
"get": {
"tags": [
"Applications"
],
"summary": "List Storages",
"description": "List all persistent storages and file storages by application UUID.",
"operationId": "list-storages-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "All storages by application UUID.",
"content": {
"application\/json": {
"schema": {
"properties": {
"persistent_storages": {
"type": "array",
"items": {
"type": "object"
}
},
"file_storages": {
"type": "array",
"items": {
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Applications"
],
"summary": "Update Storage",
"description": "Update a persistent storage or file storage by application UUID.",
"operationId": "update-storage-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"id",
"type"
],
"properties": {
"id": {
"type": "integer",
"description": "The ID of the storage."
},
"type": {
"type": "string",
"enum": [
"persistent",
"file"
],
"description": "The type of storage: persistent or file."
},
"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)."
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"200": {
"description": "Storage updated.",
"content": {
"application\/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/cloud-tokens": {
"get": {
"tags": [
@ -5971,6 +6132,387 @@
]
}
},
"\/databases\/{uuid}\/envs": {
"get": {
"tags": [
"Databases"
],
"summary": "List Envs",
"description": "List all envs by database UUID.",
"operationId": "list-envs-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Environment variables.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/EnvironmentVariable"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Databases"
],
"summary": "Create Env",
"description": "Create env by database UUID.",
"operationId": "create-env-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Env created.",
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"key": {
"type": "string",
"description": "The key of the environment variable."
},
"value": {
"type": "string",
"description": "The value of the environment variable."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
},
"is_multiline": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is multiline."
},
"is_shown_once": {
"type": "boolean",
"description": "The flag to indicate if the environment variable's value is shown on the UI."
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Environment variable created.",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string",
"example": "nc0k04gk8g0cgsk440g0koko"
}
},
"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": [
"Databases"
],
"summary": "Update Env",
"description": "Update env by database UUID.",
"operationId": "update-env-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Env updated.",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"description": "The key of the environment variable."
},
"value": {
"type": "string",
"description": "The value of the environment variable."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
},
"is_multiline": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is multiline."
},
"is_shown_once": {
"type": "boolean",
"description": "The flag to indicate if the environment variable's value is shown on the UI."
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Environment variable updated.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/EnvironmentVariable"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/envs\/bulk": {
"patch": {
"tags": [
"Databases"
],
"summary": "Update Envs (Bulk)",
"description": "Update multiple envs by database UUID.",
"operationId": "update-envs-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Bulk envs updated.",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"properties": {
"key": {
"type": "string",
"description": "The key of the environment variable."
},
"value": {
"type": "string",
"description": "The value of the environment variable."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
},
"is_multiline": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is multiline."
},
"is_shown_once": {
"type": "boolean",
"description": "The flag to indicate if the environment variable's value is shown on the UI."
}
},
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Environment variables updated.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/EnvironmentVariable"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/envs\/{env_uuid}": {
"delete": {
"tags": [
"Databases"
],
"summary": "Delete Env",
"description": "Delete env by UUID.",
"operationId": "delete-env-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "env_uuid",
"in": "path",
"description": "UUID of the environment variable.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Environment variable deleted.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Environment variable deleted."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/deployments": {
"get": {
"tags": [
@ -9685,6 +10227,11 @@
"type": "boolean",
"default": false,
"description": "Force domain override even if conflicts are detected."
},
"is_container_label_escape_enabled": {
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
}
},
"type": "object"
@ -10011,6 +10558,11 @@
"type": "boolean",
"default": false,
"description": "Force domain override even if conflicts are detected."
},
"is_container_label_escape_enabled": {
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
}
},
"type": "object"

View file

@ -2170,6 +2170,108 @@ paths:
security:
-
bearerAuth: []
'/applications/{uuid}/storages':
get:
tags:
- Applications
summary: 'List Storages'
description: 'List all persistent storages and file storages by application UUID.'
operationId: list-storages-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
responses:
'200':
description: 'All storages by application UUID.'
content:
application/json:
schema:
properties:
persistent_storages: { type: array, items: { type: object } }
file_storages: { type: array, items: { type: object } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
patch:
tags:
- Applications
summary: 'Update Storage'
description: 'Update a persistent storage or file storage by application UUID.'
operationId: update-storage-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
requestBody:
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
required: true
content:
application/json:
schema:
required:
- id
- type
properties:
id:
type: integer
description: 'The ID of the storage.'
type:
type: string
enum: [persistent, file]
description: 'The type of storage: persistent or file.'
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).'
type: object
additionalProperties: false
responses:
'200':
description: 'Storage updated.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/cloud-tokens:
get:
tags:
@ -3871,6 +3973,242 @@ paths:
security:
-
bearerAuth: []
'/databases/{uuid}/envs':
get:
tags:
- Databases
summary: 'List Envs'
description: 'List all envs by database UUID.'
operationId: list-envs-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
responses:
'200':
description: 'Environment variables.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/EnvironmentVariable'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Databases
summary: 'Create Env'
description: 'Create env by database UUID.'
operationId: create-env-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Env created.'
required: true
content:
application/json:
schema:
properties:
key:
type: string
description: 'The key of the environment variable.'
value:
type: string
description: 'The value of the environment variable.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
is_multiline:
type: boolean
description: 'The flag to indicate if the environment variable is multiline.'
is_shown_once:
type: boolean
description: "The flag to indicate if the environment variable's value is shown on the UI."
type: object
responses:
'201':
description: 'Environment variable created.'
content:
application/json:
schema:
properties:
uuid: { type: string, example: nc0k04gk8g0cgsk440g0koko }
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:
- Databases
summary: 'Update Env'
description: 'Update env by database UUID.'
operationId: update-env-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Env updated.'
required: true
content:
application/json:
schema:
required:
- key
- value
properties:
key:
type: string
description: 'The key of the environment variable.'
value:
type: string
description: 'The value of the environment variable.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
is_multiline:
type: boolean
description: 'The flag to indicate if the environment variable is multiline.'
is_shown_once:
type: boolean
description: "The flag to indicate if the environment variable's value is shown on the UI."
type: object
responses:
'201':
description: 'Environment variable updated.'
content:
application/json:
schema:
$ref: '#/components/schemas/EnvironmentVariable'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/envs/bulk':
patch:
tags:
- Databases
summary: 'Update Envs (Bulk)'
description: 'Update multiple envs by database UUID.'
operationId: update-envs-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Bulk envs updated.'
required: true
content:
application/json:
schema:
required:
- data
properties:
data:
type: array
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
type: object
responses:
'201':
description: 'Environment variables updated.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/EnvironmentVariable'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/envs/{env_uuid}':
delete:
tags:
- Databases
summary: 'Delete Env'
description: 'Delete env by UUID.'
operationId: delete-env-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
-
name: env_uuid
in: path
description: 'UUID of the environment variable.'
required: true
schema:
type: string
responses:
'200':
description: 'Environment variable deleted.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Environment variable deleted.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/deployments:
get:
tags:
@ -6152,6 +6490,10 @@ paths:
type: boolean
default: false
description: 'Force domain override even if conflicts are detected.'
is_container_label_escape_enabled:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
type: object
responses:
'201':
@ -6337,6 +6679,10 @@ paths:
type: boolean
default: false
description: 'Force domain override even if conflicts are detected.'
is_container_label_escape_enabled:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
type: object
responses:
'200':

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.468"
"version": "4.0.0-beta.469"
},
"nightly": {
"version": "4.0.0-beta.469"
"version": "4.0.0"
},
"helper": {
"version": "1.0.12"

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/svgs/imgcompress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
public/svgs/librespeed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View file

@ -13,33 +13,35 @@
@custom-variant dark (&:where(.dark, .dark *));
/* MapleDeploy branding: Canadian red accent, stone greys */
@theme {
--font-sans: Inter, sans-serif;
--font-display: 'Overlock', sans-serif;
--color-base: #101010;
--color-warning: #fcd452;
--color-base: #1c1917;
--color-warning: #fde047;
--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-400: #facc15;
--color-warning-500: #eab308;
--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-coollabs-50: #fef3f2;
--color-coollabs: #d52b1e;
--color-coollabs-100: #f34d40;
--color-coollabs-200: #bc2519;
--color-coollabs-300: #9c2118;
--color-coolgray-100: #1c1917;
--color-coolgray-200: #292524;
--color-coolgray-300: #44403c;
--color-coolgray-400: #57534e;
--color-coolgray-500: #78716c;
}
/*
@ -122,19 +124,19 @@ button[isHighlighted]:not(:disabled) {
}
h1 {
@apply text-3xl font-bold dark:text-white;
@apply text-3xl font-bold font-display dark:text-white;
}
h2 {
@apply text-xl font-bold dark:text-white;
@apply text-xl font-bold font-display dark:text-white;
}
h3 {
@apply text-lg font-bold dark:text-white;
@apply text-lg font-bold font-display dark:text-white;
}
h4 {
@apply text-base font-bold dark:text-white;
@apply text-base font-bold font-display dark:text-white;
}
a {
@ -163,7 +165,7 @@ tbody {
}
tr {
@apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200;
@apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100;
}
tr th {

View file

@ -70,3 +70,12 @@ @font-face {
src: url('../fonts/inter-v13-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2');
}
/* MapleDeploy branding: Overlock for headings */
@font-face {
font-display: swap;
font-family: 'Overlock';
font-style: normal;
font-weight: 900;
src: url('../fonts/overlock-v19-latin-900.woff2') format('woff2'),
url('../fonts/overlock-v19-latin-900.ttf') format('truetype');
}

View file

@ -40,11 +40,11 @@ @utility input-sticky {
}
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 1px #e5e5e5;
box-shadow: inset 4px 0 0 #d52b1e, inset 0 0 0 1px #e5e5e5;
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 1px #242424;
box-shadow: inset 4px 0 0 #fde047, inset 0 0 0 1px #242424;
}
}
@ -82,11 +82,11 @@ @utility input {
@apply focus-visible:outline-none;
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
box-shadow: inset 4px 0 0 #d52b1e, inset 0 0 0 2px #e5e5e5;
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
box-shadow: inset 4px 0 0 #fde047, inset 0 0 0 2px #242424;
}
&:read-only {
@ -113,11 +113,11 @@ @utility select {
}
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
box-shadow: inset 4px 0 0 #d52b1e, inset 0 0 0 2px #e5e5e5;
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
box-shadow: inset 4px 0 0 #fde047, inset 0 0 0 2px #242424;
}
}
@ -209,7 +209,8 @@ @utility icon {
}
@utility scrollbar {
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin;
/* MapleDeploy branding: yellow scrollbar thumb instead of Coolify red */
@apply scrollbar-thumb-warning scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin;
}
@utility main {
@ -269,7 +270,8 @@ @utility description {
}
@utility bg-coollabs-gradient {
@apply from-purple-500 via-pink-500 to-red-500 bg-linear-to-r;
/* MapleDeploy branding */
@apply from-red-700 via-red-500 to-red-400 bg-linear-to-r;
}
@utility text-helper {
@ -333,7 +335,7 @@ @utility log-warning {
}
@utility log-debug {
@apply bg-purple-500/10 dark:bg-purple-500/15;
@apply bg-stone-500/10 dark:bg-stone-500/15;
}
@utility log-info {

Binary file not shown.

Binary file not shown.

View file

@ -3,9 +3,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
Confirm Your Password
</p>

View file

@ -3,9 +3,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.forgot_password_heading') }}
</p>

View file

@ -3,12 +3,33 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
</div>
<div class="space-y-6">
@if (!empty($setup_pending))
{{-- MapleDeploy: setup token required but not provided --}}
<div class="mb-6 p-4 bg-warning/10 border border-warning rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-warning flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd" />
</svg>
<div>
<p class="font-bold text-warning">Setup pending</p>
<p class="text-sm dark:text-white text-black">
Initial setup has not been completed. Please use the setup link from your
<a href="https://app.mapledeploy.ca" class="underline hover:text-warning">MapleDeploy dashboard</a>.
</p>
</div>
</div>
</div>
@else
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
@ -95,6 +116,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
@endforeach
</div>
@endif
@endif {{-- end setup_pending --}}
</div>
</div>
</div>

View file

@ -15,9 +15,10 @@ function getOldOrLocal($key, $localValue)
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
Create your account
</p>
@ -52,6 +53,9 @@ function getOldOrLocal($key, $localValue)
<form action="/register" method="POST" class="flex flex-col gap-4">
@csrf
@if (isset($setupToken))
<input type="hidden" name="setup_token" value="{{ $setupToken }}" />
@endif
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}"
label="{{ __('input.name') }}" />
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"

View file

@ -3,9 +3,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.reset_password') }}
</p>

View file

@ -47,9 +47,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
Two-Factor Authentication
</p>

View file

@ -48,7 +48,7 @@
{!! $icon !!}
</div>
<div class="ml-3 {{ $dismissible ? 'pr-8' : '' }}">
<div class="text-base font-bold {{ $colorScheme['title'] }}">
<div class="text-base font-bold font-display {{ $colorScheme['title'] }}">
{{ $title }}
</div>
<div class="mt-2 text-sm {{ $colorScheme['text'] }}">

View file

@ -1,6 +1,7 @@
{{ Illuminate\Mail\Markdown::parse('---') }}
Thank you,<br>
{{ config('app.name') ?? 'Coolify' }}
{{ config('app.name') ?? 'MapleDeploy' }}
{{ Illuminate\Mail\Markdown::parse('[Contact Support](https://coolify.io/docs/contact)') }}
{{-- MapleDeploy branding: support link --}}
{{ Illuminate\Mail\Markdown::parse('[Contact Support](https://mapledeploy.ca/contact)') }}

View file

@ -99,9 +99,10 @@
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text px-1 text-black dark:text-white"
{{-- MapleDeploy branding: red accent instead of Coolify purple --}}
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const accent = isDark ? '#fde047' : '#d52b1e';
const border = isDark ? '#242424' : '#e5e5e5';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
@ -110,7 +111,7 @@ class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50"
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
@ -231,9 +232,10 @@ class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:
{{-- Input Container --}}
<div @click="openDropdown()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text text-black dark:text-white"
{{-- MapleDeploy branding: red accent instead of Coolify purple --}}
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const accent = isDark ? '#fde047' : '#d52b1e';
const border = isDark ? '#242424' : '#e5e5e5';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
@ -241,7 +243,7 @@ class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 bg-whit
})()"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]">
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">

View file

@ -36,7 +36,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
@endif
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
@ -47,7 +47,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
@else
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"

View file

@ -12,7 +12,7 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu
@endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @else wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif>
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @else wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif>
{{ $slot }}
</select>
@error($modelBinding)

View file

@ -45,16 +45,16 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
@endif
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]"
@else
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
@ -64,9 +64,9 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}"
{{ $allowTab ? '@keydown.tab=handleKeydown' : '' }} placeholder="{{ $placeholder }}"
{{ !$spellcheck ? 'spellcheck=false' : '' }} {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]"
@else
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>

Some files were not shown because too many files have changed in this diff Show more