Compare commits

...

158 commits

Author SHA1 Message Date
rosslh
3f5f84e282 chore(ci): remove upstream GitHub workflow
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m27s
2026-05-01 19:15:43 -04:00
rosslh
33051b0050 style: hue-normalize red palette to brand OKLCH h=29.38 2026-05-01 19:01:08 -04:00
rosslh
74a776ebc1 style(branding): fix squished mobile header logo 2026-05-01 19:01:08 -04:00
rosslh
2e1fb4bf28 style(branding): remap neutral/gray to stone and fix chart theming
Remap neutral and gray scales to stone equivalents in @theme block for
warm tone consistency across all three apps. Interpolate coolgray-200 to
avoid invisible dark-mode elements. Add gridColor/textColor to ApexCharts
initial options so axis labels render on first load. Update warning color
and fix add-tag opacity.
2026-05-01 19:01:08 -04:00
rosslh
6e13d87671 style(branding): replace gray-50 with stone-50 and darken base to stone-800 2026-05-01 19:01:08 -04:00
rosslh
c86e4d5751 fix(sentinel): pull sentinel image from ghcr.io and fall back to latest tag 2026-05-01 19:01:08 -04:00
rosslh
9a38f6da62 feat(branding): disable FQDN and IP fields instead of hiding/leaving editable 2026-05-01 19:01:08 -04:00
rosslh
d438d1ca70 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-05-01 19:01:08 -04:00
rosslh
dcb229ca18 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-05-01 19:01:08 -04:00
rosslh
a454c02b48 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-05-01 19:01:08 -04:00
rosslh
79a66ac810 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-05-01 19:01:08 -04:00
rosslh
fc8a3fcd57 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-05-01 19:01:08 -04:00
rosslh
78ea904509 chore(branding): remove sponsorship popup remnants 2026-05-01 19:01:08 -04:00
rosslh
389df56e25 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-05-01 19:01:08 -04:00
rosslh
f25a093a1b 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-05-01 19:01:08 -04:00
rosslh
7046794f78 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-05-01 19:01:08 -04:00
rosslh
9da3e65afa fix(branding): preserve sidebar logo aspect ratio when constrained 2026-05-01 19:01:08 -04:00
rosslh
f540146045 fix(branding): replace "self-hosted" subtitle on dashboard 2026-05-01 19:01:08 -04:00
rosslh
25d222cca4 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-05-01 19:01:08 -04:00
rosslh
0ad36e2a60 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-05-01 19:01:08 -04:00
rosslh
28799bd795 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-05-01 19:01:08 -04:00
rosslh
680b9305a2 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-05-01 19:00:45 -04:00
rosslh
bc26fb6fe1 fix(ci): use NY region storage endpoint for Bunny CDN uploads 2026-05-01 19:00:45 -04:00
rosslh
e563a9c79a fix(ci): use apk instead of apt-get for curl install 2026-05-01 19:00:45 -04:00
rosslh
702b47788f fix(ci): install curl in runner for CDN uploads 2026-05-01 19:00:45 -04:00
rosslh
20a4b53950 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-05-01 19:00:45 -04:00
rosslh
21bb24fe6f 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-05-01 19:00:45 -04:00
Andras Bacsai
922950de59
chore(templates): sync service-templates from next (#9884) 2026-04-30 07:05:35 +02:00
Andras Bacsai
51d6795eeb chore(templates): sync service-templates from next
Pulls latest service-templates JSON files from `next` so cloud's hourly
PullTemplatesFromCDN job picks up queued template fixes (Jitsi, Plane,
Cap, Beszel, Langfuse, Twenty, Cal.com, etc.).

`templates/**` is in `paths-ignore` of coolify-production-build.yml so
no image rebuild triggered.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 07:04:12 +02:00
Andras Bacsai
96bfc14543
v4.0.0 (#9818) 2026-04-27 10:51:15 +02:00
Andras Bacsai
d0ed4fa4c4 version ++ finally 2026-04-27 09:09:01 +02:00
Andras Bacsai
8deb19e5e4
fix(service): rally invalid next public url (#9041) 2026-04-27 09:08:04 +02:00
Andras Bacsai
e1a295ac6c
fix(service): add missing database alteration step for Logto latest image (#9376) 2026-04-27 09:07:53 +02:00
Andras Bacsai
5a4f79441c
fix(service): Jitsi Meet doesn't work (#9594) 2026-04-27 09:06:57 +02:00
Andras Bacsai
349e9289ad
fix(service): twenty fails to deploy due to dependency unhealthy (#9603) 2026-04-27 09:06:41 +02:00
Andras Bacsai
43b8304e02
feat(service): enable plane (#9641) 2026-04-27 09:06:32 +02:00
Andras Bacsai
e9a9a32e87
feat(services): add Cap to templates (#9729) 2026-04-27 09:06:09 +02:00
Andras Bacsai
3f736b5afd
feat(service): add healthcheck to langfuse-worker (#9772) 2026-04-27 09:05:38 +02:00
Andras Bacsai
66ce3009a3
chore(service): update beszel to 0.18.7 (#9775) 2026-04-27 09:05:22 +02:00
Andras Bacsai
d131066701
feat(service): disable calcom (#9776) 2026-04-27 09:04:56 +02:00
Andras Bacsai
968ae97dfc version++ 2026-04-27 09:01:36 +02:00
Andras Bacsai
8ef238d19c
fix(helper): stop info icon click from propagating to parent on mobile (#9809) 2026-04-26 13:54:59 +02:00
Andras Bacsai
9cd379e737 fix(helper): add Alpine.js click toggle to info helper popup
Replace CSS-only hover with Alpine.js click-based open/close,
including click.outside to dismiss.
2026-04-26 12:55:34 +02:00
Andras Bacsai
cad9fc99d6 docs(sponsors): add ParsecPH to Small sponsors 2026-04-26 12:53:45 +02:00
Andras Bacsai
74cc85139f docs(sponsors): add NetRouting to Small sponsors 2026-04-24 22:33:32 +02:00
ShadowArcanist
d2b7dfe92a
fix(service): remove volume declaration on jitsi 2026-04-24 09:40:01 +05:30
ShadowArcanist
424a41dbd0
fix(service): add missing category to jitsi 2026-04-24 09:30:57 +05:30
ShadowArcanist
cd47711cd0
feat(service): disable calcom
Not maintained anymore by the calcom team
2026-04-24 02:28:08 +05:30
ShadowArcanist
5f45deedce
chore(service): update beszel-agent to 0.18.7 2026-04-24 02:22:08 +05:30
ShadowArcanist
b3d6877404
chore(service): update beszel to 0.18.7 2026-04-24 02:21:33 +05:30
ShadowArcanist
32ae288a12
fix(service): add port to metadata on plane 2026-04-24 00:12:17 +05:30
Gauthier POGAM--LE MONTAGNER
f77fd2161c feat(service): add healthcheck to langfuse-worker 2026-04-23 18:08:40 +02:00
Andras Bacsai
c5ce36018c docs(sponsors): add MindedTech to Small sponsors 2026-04-23 14:13:55 +02:00
Andras Bacsai
237313f5c7 docs(sponsors): update PrivateAlps description 2026-04-23 00:17:53 +02:00
ShadowArcanist
e26d4e39e6
fix(service): add healthcheck on cap-captcha.yaml 2026-04-23 01:07:14 +05:30
ShadowArcanist
716c741fff
fix(service): pin docker image on cap-captcha.yaml 2026-04-23 01:07:00 +05:30
ShadowArcanist
d425998476
fix(service): service url variable on cap-captcha.yaml 2026-04-23 01:06:44 +05:30
ShadowArcanist
ae1a24a83b
fix(service): add category on cap-captcha.yaml 2026-04-23 01:06:25 +05:30
ShadowArcanist
833f5769e5
fix(service): docs link on cap-captcha.yaml 2026-04-23 01:06:07 +05:30
Andras Bacsai
6b00c31ff2
fix(navigation): replace wire:navigate.hover with wire:navigate (#9742) 2026-04-22 20:56:45 +02:00
Andras Bacsai
19767a569b fix(navigation): replace wire:navigate.hover with wire:navigate
Remove hover prefetching variant from SPA navigation helper,
both in the happy path and the exception fallback.
2026-04-22 20:55:09 +02:00
tiago
0c1c5c5831
feat: add Cap to templates 2026-04-22 10:15:18 +01:00
Andras Bacsai
4e561264b4 docs(sponsors): add PrivateAlps to Huge and YouStable to Small sponsors 2026-04-22 08:58:38 +02:00
Andras Bacsai
37518813a6
v4.0.0-beta.474 (#9542) 2026-04-21 12:30:44 +02:00
Andras Bacsai
e1aac50b74
refactor(validation): tokenize shell-safe command pattern (#9684) 2026-04-20 22:04:36 +02:00
Andras Bacsai
817128c5af refactor(validation): tokenize shell-safe command pattern
Replace the flat character-class regex for SHELL_SAFE_COMMAND_PATTERN with
a token-aware alternation. The parser now recognizes explicit tokens
(`&&`, `||`, balanced single/double quotes, whitespace, and an unquoted
safe-char run) instead of a bag of characters, which lets us extend the
accepted grammar without loosening the guarantees.

New surface area, with tests:
- logical OR chaining (`make build || make clean`)
- shell globs and bang (`rm *.tmp`, `cp src/?.js dist/`, `! grep -q foo`)
- single-quoted arguments are now treated as balanced runs rather than
  rejected per-character

Preserved surface area:
- && chaining, balanced "..." and '...' quotes, the previous safe path /
  argument characters, and the existing error-path contract in
  ApplicationDeploymentJob::validateShellSafeCommand().

Also refreshes the user-facing validation messages in General.php so the
allow/deny list shown on failure matches the new grammar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:00:41 +02:00
Andras Bacsai
8e22360139
refactor(database): align Postgres SSL chown escaping with MySQL (#9682) 2026-04-20 21:44:02 +02:00
Andras Bacsai
f0e955bf45 refactor(database): escape postgres_user in SSL chown command
Apply escapeshellarg() to the Postgres username before interpolating it
into the chown command used to fix SSL certificate ownership, matching
the handling already in place for StartMysql. This keeps the sink-side
escaping consistent across database actions, independent of upstream
input validation.

Also adjusts an assertion in DatabaseSslCredentialEscapingTest to match
the actual double-escaped output of executeInDocker, and adds Postgres
regression cases for subshell and semicolon payloads.
2026-04-20 21:41:48 +02:00
Andras Bacsai
1cf6c7d0ae
fix(database): tighten Postgres init script filename handling (#9681) 2026-04-20 21:27:56 +02:00
Andras Bacsai
a05d4e3a4b fix(database): tighten Postgres init script filename handling
Validate new init-script filenames against path traversal and shell
metacharacters via a new validateFilenameSafe() helper, and harden the
write/delete paths with basename() + escapeshellarg() so legacy rows
still deploy and can be cleaned up without regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 21:26:34 +02:00
Andras Bacsai
b1a78df58e
feat(security): add expiration support for API tokens (#9677) 2026-04-20 14:29:28 +02:00
Andras Bacsai
90ddbb3572 feat(security): support expiration on API tokens with warning notifications
Add optional expiration to personal API tokens. Users pick a duration
(1/7/30/60/90 days or Never) at creation time. Expired tokens are
rejected by Sanctum, pruned hourly by sanctum:prune-expired, and a
team notification fires ~24h before expiry so owners can rotate
before API calls start failing.

- ApiTokens Livewire component stores expires_at from expiresInDays
- Rework issued-tokens UI from card grid to table (matches other views)
- New ApiTokenExpirationWarningJob scheduled hourly (idempotent via RateLimiter)
- New ApiTokenExpiringNotification (email/discord/telegram/slack/pushover)
- api_token_expiring added to alwaysSendEvents so users cannot silence
  expiry warnings from the per-event notification toggle UI
- sanctum:prune-expired cadence moved from daily to hourly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 14:28:38 +02:00
Andras Bacsai
bff6d85370
fix(database): credential format validation with dirty-value escape hatch (#9676) 2026-04-20 14:24:06 +02:00
Andras Bacsai
40a9881ef2 fix(database): skip credential pattern validation for unchanged values
Pattern enforcement now conditional on field being dirty (changed vs
saved value). Prevents false validation failures when existing records
hold legacy credential formats that pre-date the stricter regex rules.
2026-04-20 13:58:44 +02:00
Andras Bacsai
03313e54cc fix(database): enforce credential format validation and sanitize init/SSL arguments
Add ValidationPatterns helpers for database identifiers and passwords,
apply them across database Livewire components and the API controller,
encode MongoDB init script values via json_encode, and pass the MySQL
user through escapeshellarg when generating SSL chown commands.
2026-04-20 13:58:36 +02:00
Andras Bacsai
2264a2ef76 docs(tests): replace advisory ID with descriptive comment in healthcheck injection test 2026-04-20 13:28:55 +02:00
Andras Bacsai
b74f54302b
fix(database): mount guard, healthcheck CMD exec-form, port input layout (#9674) 2026-04-20 13:18:27 +02:00
Andras Bacsai
1002d211d0 style(database): wrap public port inputs in flex-col gap-2 container
Add wrapper div around publicPort and publicPortTimeout inputs across
all database general settings views for consistent vertical spacing.
2026-04-20 13:17:15 +02:00
Andras Bacsai
64753b4136 fix(database): prevent command injection in healthcheck via CMD exec-form
Replace CMD-SHELL string interpolation with CMD exec-form arrays in
healthcheck configs for PostgreSQL, Dragonfly, KeyDB, and ClickHouse.

CMD-SHELL passes the string to /bin/sh -c, allowing command injection
through user-controlled fields (username, password, dbname). CMD
exec-form bypasses the shell entirely — each value is a discrete argv
element.

Fixes GHSA-gvc4-f276-r88p.

Adds regression tests covering semicolon, pipe, backtick, $(),
background operator, redirect, newline, and null-byte injection vectors.
2026-04-20 13:17:15 +02:00
Andras Bacsai
245c6a18c8 Merge remote-tracking branch 'origin/next' into fix/empty-db-custom-config-mount 2026-04-20 13:15:57 +02:00
Andras Bacsai
03bf3d5353 fix(database): use && instead of || for conf null/empty checks
`||` caused config volumes to mount even when conf was null,
since `!is_null(null)` is false but `!empty(null)` is true —
condition always evaluated to true.
2026-04-20 13:12:16 +02:00
Andras Bacsai
38881df66f
refactor: harden auth, CLI input, and scheduled-log viewer (#9672) 2026-04-20 12:19:11 +02:00
Andras Bacsai
bb0c3501ef refactor(cli): validate --date and escape shell args on logs:scheduled
Reject malformed --date values with a clear error before building any
shell command, and wrap every interpolated value (log paths, filter
expression, line count) in escapeshellarg() so filter options and date
values can no longer break out of the tail/grep pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 12:09:48 +02:00
Andras Bacsai
49b5472961 refactor(auth): upgrade email verification hash to sha256
Move the email-verification URL hash from sha1 to sha256 and verify it
directly in the controller using hash_equals, instead of going through
Laravel's EmailVerificationRequest (which only compares against sha1).
The signed URL still carries the authoritative HMAC; the hash upgrade
keeps the identity binding aligned with modern hashing guidance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 12:09:48 +02:00
Andras Bacsai
9b37a1a7eb refactor(auth): drop implicit email verification on invitation link login
The invitation-link login path previously marked the account as
email-verified as a side effect of authenticating, without the user ever
proving control of the mailbox. Remove that branch so every account
goes through the standard signed-URL verification flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 12:09:48 +02:00
Andras Bacsai
e373037a2a test: remove GHSA advisory IDs from test descriptions and comments
Strip advisory identifiers (GHSA-*) from describe blocks, test
docblocks, and inline comments. Replace with plain descriptive
labels. Also clean up FQCNs to use imported class names and minor
style fixes (string concatenation spacing).
2026-04-20 12:07:42 +02:00
Andras Bacsai
32d9697909
chore: mark v4 docker swarm support as deprecated (#9621) 2026-04-20 12:04:56 +02:00
Andras Bacsai
ea639dab8f
refactor(api): return stable generic error messages for 5xx responses (#9669) 2026-04-20 11:53:20 +02:00
Andras Bacsai
fe8b3412d3
refactor(settings): harden dev_helper_version validation and escape build args (#9670) 2026-04-20 11:52:48 +02:00
Andras Bacsai
03a35faf2c
refactor(storage): tighten S3 endpoint URL validation (#9668) 2026-04-20 11:51:51 +02:00
Andras Bacsai
dc9322b11f refactor(settings): validate dev_helper_version and escape build args
Constrain dev_helper_version to Docker tag grammar
([A-Za-z0-9_][A-Za-z0-9_.-]{0,127}), re-validate before triggering the
helper image build, and interpolate the image reference via
escapeshellarg() when composing the docker build command.
2026-04-20 11:51:27 +02:00
Andras Bacsai
4d83688896 refactor(api): return generic error messages for upstream and storage failures
Replace exception text in 5xx JSON responses with stable, action-specific
messages so API consumers get a consistent payload regardless of which
underlying client (Guzzle, PDO, filesystem) raised the exception. The
previous responses concatenated the raw upstream error, which produced
inconsistent messages and unnecessary noise for clients trying to parse
errors programmatically.

Touched endpoints:
- GET /api/v1/hetzner/{locations,server-types,images,ssh-keys}
- POST /api/v1/servers/hetzner
- DELETE /api/v1/databases/{uuid}/backups/{uuid}
- DELETE /api/v1/databases/{uuid}/backups/{uuid}/executions/{uuid}
- /download/backup/{uuid}

The RateLimitException branch and AuthenticationException flow keep their
existing curated messages.

Adds Pest coverage for the four Hetzner GET endpoints to lock the response
shape on upstream failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 11:50:30 +02:00
Andras Bacsai
297e9c41e1 refactor(storage): tighten S3 endpoint URL validation
Reuse the existing SafeWebhookUrl rule on the S3 Storage endpoint field so
the create and edit forms go through the same URL-normalization path as
webhook settings. Adds a matching guard inside S3Storage::testConnection()
so background callers (scheduled backups, database import reuse) also
validate the endpoint before building the S3 client.

Also fixes an IPv6-bracket edge case in SafeWebhookUrl so `http://[::1]`
style hosts are normalized before the filter_var IP check — the rule's
own loopback test was already asserting this behaviour.
2026-04-20 11:50:19 +02:00
Andras Bacsai
e6a6446dae
refactor(backup): validate database backup upload file type and size (#9667) 2026-04-20 11:46:31 +02:00
Andras Bacsai
af0a8badb3 refactor(backup): validate database backup upload file type and size
Add allowlist of backup file extensions (sql, sql.gz, tar, tgz, zip,
dump, bak, bson, archive, bz2, xz, and compound variants) and enforce
a 10 GiB maximum file size on the backup upload endpoint. Validation
runs early on each chunk using the dropzone metadata and again on the
assembled file. Also drops the unused createFilename helper and the
commented-out S3 block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 11:45:00 +02:00
Andras Bacsai
e1f40903c3
refactor(volumes): validate input and escape shell args (#9666) 2026-04-20 11:28:18 +02:00
Andras Bacsai
410a9a6195 refactor(volumes): validate input and escape shell args
Tighten validation on volume name and host path inputs across Livewire + API storage endpoints and escape shell arguments in volume clone and compose preview cleanup paths.
2026-04-20 11:27:10 +02:00
🏔️ Peak
d392bab4c1
Merge branch 'next' into v5.x-chore/deprecate-docker-swarm 2026-04-20 01:12:43 +02:00
Andras Bacsai
a1b2ab124a
fix(api): use explicit team ID for S3 storage lookup in backup endpoints (#9655) 2026-04-19 15:28:13 +02:00
Andras Bacsai
5019c8db92 fix(api): use explicit team ID for S3 storage lookup in backup endpoints
Replace `ownedByCurrentTeam()` (session-based) with `ownedByCurrentTeamAPI($teamId)`
(explicit team ID) when resolving S3 storage in create_backup and update_backup.
Session-based team resolution is unreliable in API context where auth is token-based.

Add `S3Storage::ownedByCurrentTeamAPI(int $teamId)` scope and update feature tests
to use real model instances instead of Mockery mocks.
2026-04-19 15:26:47 +02:00
Andras Bacsai
661d609da3
fix(server): exclude persistent resources from container prune (#9654) 2026-04-19 15:19:41 +02:00
Andras Bacsai
0620496c5f fix(server): exclude persistent resources from container prune
Prevent docker container prune from removing containers labeled as
database, application, or service types. Previously only proxy containers
were excluded, risking accidental cleanup of active resources.
2026-04-19 15:17:47 +02:00
Andras Bacsai
371e883c75
refactor(api): validate and throttle feedback endpoint (#9653) 2026-04-19 14:50:03 +02:00
Andras Bacsai
434f91f83c refactor(help): raise feedback subject cap to 600 characters
Align composed payload size with the 2000-char backend budget
(prefix ~56 + email 255 + subject 600 + description 1000 = 1911).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 14:48:34 +02:00
Andras Bacsai
233f063850 refactor(help): cap feedback subject length to 255 characters
Keep composed feedback payload within the server-side 2000-char budget
(prefix ~56 + email 255 + subject 255 + description 1000 = 1566).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 14:46:42 +02:00
Andras Bacsai
5bf4bb9e80
feat(api): add DELETE endpoint for preview deployments by PR id (#9614) 2026-04-19 14:43:32 +02:00
Andras Bacsai
e7bbd45408 refactor(api): validate and throttle feedback endpoint
- Validate content (required string, min:10, max:2000) in OtherController@feedback
- Register 'feedback' named rate limiter (3/min per user or IP) in RouteServiceProvider
- Apply throttle:feedback middleware to POST /api/feedback
- Forward to Discord with allowed_mentions.parse=[] and a 5s HTTP timeout

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 14:41:47 +02:00
Andras Bacsai
1337e4351a
refactor(webhook): encrypt manual webhook secrets and tighten HMAC verification (#9652) 2026-04-19 12:53:02 +02:00
Andras Bacsai
bafb9a5a8b refactor(webhook): encrypt manual webhook secrets and tighten HMAC verification
- Auto-generate a 40-char random secret for each manual_webhook_secret_* column on Application creation so new apps are never left with an empty secret.
- Add encrypted cast for the four webhook-secret columns; backfill migration re-encrypts existing plaintext values and fills missing ones.
- Reject webhook deliveries when the stored secret is empty (GitHub, GitLab, Bitbucket, Gitea manual endpoints).
- Bitbucket: require the sha256 algorithm prefix on X-Hub-Signature instead of trusting the client-supplied algo.
- GitLab: drop the ?? '' fallback on the token comparison.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 12:52:23 +02:00
Andras Bacsai
0627e14810
build(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0 (#9580) 2026-04-19 12:04:56 +02:00
Andras Bacsai
33518b24a2
refactor: tighten team scoping on resource creation and admin nav (#9651) 2026-04-19 12:01:11 +02:00
Andras Bacsai
f77cc91b83 refactor(admin): use named routes for admin index navigation
Replace Referer-based redirects in Admin Index back() and switchUser()
with named routes (admin.index and dashboard) for consistent navigation
behavior independent of the request header.

Add tests verifying back() returns to admin.index, switchUser routes to
the dashboard, and the Referer header is no longer consulted.
2026-04-19 11:58:52 +02:00
Andras Bacsai
a478ac66eb refactor: scope destination and resource lookups by current team
Use find_destination_for_current_team helper across resource creation
flows and the destination controller. Pass full destination objects to
database creation helpers instead of UUIDs so team relationships are
resolved consistently before the resource is created or linked.

Add feature tests covering destination, backup storage, and resource
proof lookups across teams.
2026-04-19 11:55:12 +02:00
DarkMaper
beeed85e43
feat(service): enable plane 2026-04-18 23:35:59 +02:00
DarkMaper
53b47b0baa
feat(service): update docker-compose according to the official doc 2026-04-18 23:35:37 +02:00
peaklabs-dev
15cb9446ff
chore(swarm): mark docker swarm as deprecated 2026-04-17 23:41:39 +02:00
peaklabs-dev
340cd70ae5
chore(ui): add a deprecated notice component 2026-04-17 23:31:30 +02:00
Andras Bacsai
bceb5f28dc feat(applications): add DELETE endpoint for preview deployments by PR id
Add `DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}` to
cancel active deployments, stop containers, and delete the preview
record via `CleanupPreviewDeployment`. Includes OpenAPI annotations,
input validation, and full feature test coverage.
2026-04-17 13:29:11 +02:00
ShadowArcanist
e18ac5a7e8
fix(service): twenty fails to deploy due to dependency unhealthy 2026-04-16 23:18:19 +05:30
miqonee
c07053d28b fix(templates): restore Jitsi Meet service template (#4813) 2026-04-16 18:45:27 +07:00
dependabot[bot]
0daf450efb
build(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 19:04:15 +00:00
Andras Bacsai
3a8f52ce16 fix(team): mark servers unreachable when subscription ends
Set unreachable_count to 3 and unreachable_notification_sent to true
on all team servers in subscriptionEnded(), so the existing cleanup
command can pick them up after the 7-day grace period.

Also adds feature tests for the subscription-ended cleanup flow and
casts server IP to string in existing unreachable server tests to fix
type comparison.
2026-04-15 15:12:29 +02:00
Andras Bacsai
a5b3d3a536 fix(migrations): guard uuid column addition and filter teamless servers
- Skip uuid column creation if it already exists to prevent duplicate
  column errors on re-run
- Use chunkById instead of orderBy+chunk for efficient pagination
- Filter servers by whereHas('team') to avoid processing orphaned servers
  without a team relationship
2026-04-15 14:24:41 +02:00
Andras Bacsai
68e8d6904d feat(env): add buildtime and runtime checkboxes for shared variables
Add is_buildtime and is_runtime checkboxes to shared environment
variable UI, shown in both editable and read-only (disabled) states.
2026-04-14 17:14:49 +02:00
Andras Bacsai
3fa4ea9ad3
fix(git): preserve ssh scheme URLs with custom ports (#9425) 2026-04-14 15:31:31 +02:00
Andras Bacsai
df5a9e9ba3 chore(version): bump Coolify to 4.0.0-beta.474
Update the app version constant and synced version manifests for the latest beta release.
2026-04-14 11:29:50 +02:00
Andras Bacsai
7667146678
Added extra documentation on format for port+path for domains (#8331) 2026-04-14 11:18:35 +02:00
Andras Bacsai
988c127e20 Merge remote-tracking branch 'origin/next' into domain-info-addition 2026-04-14 11:17:59 +02:00
Andras Bacsai
7196b05777 Merge remote-tracking branch 'origin/next' into domain-info-addition 2026-04-14 11:17:40 +02:00
Andras Bacsai
b20729d47f
feat(services): add architecture warning (#8390) 2026-04-14 11:16:57 +02:00
Andras Bacsai
6b609536a5 fix(templates): mark Cal.com as AMD-only
Add the `amd_only` flag to the Cal.com service entry in both template JSON files so it is constrained to supported architecture.
2026-04-14 11:16:18 +02:00
Andras Bacsai
aa445b4a6c Resolve remaining merge conflicts 2026-04-14 11:14:22 +02:00
Andras Bacsai
7a6e88175c
feat(ui): improve service settings UX, headings, and helper text for clarity (#9027) 2026-04-14 10:54:01 +02:00
Andras Bacsai
07c6b02a82 Merge remote-tracking branch 'origin/next' into jean/organize-service-ui 2026-04-14 10:52:01 +02:00
Andras Bacsai
9f86b73d65
fix(healthcheck): user input is rejected if path contains comma and semicolon (#9223) 2026-04-14 10:41:55 +02:00
Andras Bacsai
09f433b266
feat(ui): display memory limit fields in single row (#9232) 2026-04-14 10:39:37 +02:00
Andras Bacsai
f246e0aa99
feat(ui): add info callout to clone resource section about excluded items (#9233) 2026-04-14 10:39:00 +02:00
Andras Bacsai
340c5dc039
feat(ui): categorize application advanced settings into logical sections (#9234) 2026-04-14 10:38:43 +02:00
Andras Bacsai
91d5f46485
fix(dev): add Docker volume path mapping to testing-host for database deployments (#9534) 2026-04-14 10:38:23 +02:00
Andras Bacsai
5eb2c90c04
fix(installer): use RHEL Docker repo for Rocky Linux (#9541) 2026-04-14 10:31:54 +02:00
Andras Bacsai
16d9c02e73 fix(install): use Rocky Linux RHEL Docker repository
Add a Rocky-specific Docker install path to the stable and nightly install scripts, using Docker's documented RHEL repository flow. Include a unit test to lock in the Rocky repo selection and command set.
2026-04-14 10:31:01 +02:00
Tim L. White
067dd35e2b fix(dev): add Docker volume path mapping to testing-host for database deployments
Database deployments generate bind mounts referencing the internal Docker
volume path (/var/lib/docker/volumes/coolify_dev_coolify_data/_data) which
doesn't exist in the testing-host container. This adds the missing volume
mount so both /data/coolify and the Docker volume path resolve correctly.

Fixes #9533

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:13:00 -06:00
Iisyourdad
d2ada90a47 fix(git): harden ssh URL normalization 2026-04-07 22:41:15 -05:00
Iisyourdad
f877985e56 fix(git): preserve ssh scheme URLs with custom ports 2026-04-04 14:49:34 -05:00
Fábio Henrique Araújo
903837c96a fix: add missing database alteration step for latest image version
When upgrading to the latest image version, the database alteration command was missing, causing the application to fail due to schema mismatch. This change ensures the database is properly migrated to the latest version during startup.
2026-04-01 13:19:47 +00:00
ShadowArcanist
1a59c4c7c9 feat(ui): categorize application advanced settings into logical sections 2026-03-28 19:25:50 +05:30
ShadowArcanist
4fd90171eb feat(ui): add info callout to clone resource section about excluded items 2026-03-28 18:57:07 +05:30
ShadowArcanist
987f49b6d1 feat(ui): display memory limit fields in single row 2026-03-28 18:22:31 +05:30
ShadowArcanist
b18de3af9a fix(healthcheck): accept comma and semicolon in health check path validation 2026-03-28 16:31:12 +05:30
Mattia Trapani
485a57fb1b
SMTP_TLS_ENABLED deprecated 2026-03-19 16:11:50 +01:00
Mattia Trapani
3afc1d46de
Fix environment variable defaults in rallly.yaml
- SERVICE_URL already includes protocol
- SMTP_SECURE and SMTP_TLS_ENABLED need a default value
2026-03-19 16:08:39 +01:00
ShadowArcanist
820f699853 fix(ui): updated example domains on helper text to be https instead of http
we were setting a bad example by showing http because user will enter their domain in http and wonder why they cannot access their site over https, also user don't know how to use multiple domains with port numbers so covered it on this change as well
2026-03-18 12:53:24 +05:30
ShadowArcanist
49ffbb9429 fix(ui): changed required port callout from warning to info
it's basically an info for the end user about the port requirement, in the past we had some users confused by it like their domain works fine even without port number while the service port is 80
2026-03-18 12:09:52 +05:30
ShadowArcanist
8a2f989b6e feat(ui): added advanced page on service settings page
basically moved advanced section from general page to it's own new page called advanced
2026-03-18 12:00:42 +05:30
ShadowArcanist
8105447f94 feat(ui): added network heading on services page for network related options 2026-03-18 11:45:02 +05:30
Cinzya
ed0037a1f3 chore: mark calcom amd only 2026-02-16 19:19:59 +01:00
Cinzya
dfd0edff64 feat(services): add architecture warning 2026-02-16 19:19:59 +01:00
James Peters
93a95bdf43 Added extra documentation on format for port + path when configuring domain rewrites 2026-02-14 00:03:12 +00:00
309 changed files with 6449 additions and 2667 deletions

View file

@ -50,7 +50,7 @@ #### Semantic
| Token | Hex | Usage |
|---|---|---|
| `success` | `#22C55E` | Running status, success alerts |
| `error` | `#dc2626` | Stopped status, danger actions, error alerts |
| `error` | `#dc281c` | Stopped status, danger actions, error alerts |
#### Light Mode Defaults
@ -650,7 +650,7 @@ ### 3.7 Badge / Status Indicator
.badge-success { background: #22C55E; }
.badge-warning { background: #fcd452; }
.badge-error { background: #dc2626; }
.badge-error { background: #dc281c; }
```
#### Status Text Pattern
@ -669,7 +669,7 @@ #### Status Text Pattern
| Status | Badge Class | Text Color |
|---|---|---|
| Running | `badge-success` | `text-success` (`#22C55E`) |
| Stopped | `badge-error` | `text-error` (`#dc2626`) |
| Stopped | `badge-error` | `text-error` (`#dc281c`) |
| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) |
| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) |
@ -1129,7 +1129,7 @@ ### 3.10 Callout / Alert
**Icon colors per type:**
- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`)
- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`)
- Danger: `text-red-600 dark:text-red-400` (`#dc281c` / `#f87171`)
- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`)
- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`)
@ -1661,6 +1661,6 @@ ## 6. CSS Custom Properties (Theme Tokens)
/* Semantic */
--color-success: #22C55E;
--color-error: #dc2626;
--color-error: #dc281c;
}
```

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

@ -1,111 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# General Settings
max-failures: 4
# PR Branch Checks
allowed-target-branches: "next"
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: |
main
master
v4.x
# PR Quality Checks
max-negative-reactions: 0
require-maintainer-can-modify: true
# PR Title Checks
require-conventional-title: true
# PR Description Checks
require-description: true
max-description-length: 2500
max-emoji-count: 2
max-code-references: 5
require-linked-issue: false
blocked-terms: |
STRAWBERRY
🤖 Generated with Claude Code
Generated with Claude Code
blocked-issue-numbers: 8154
# PR Template Checks
require-pr-template: true
strict-pr-template-sections: "Contributor Agreement"
optional-pr-template-sections: "Issues,Preview"
max-additional-pr-template-sections: 2
# Commit Message Checks
max-commit-message-length: 500
require-conventional-commits: false
require-commit-author-match: true
blocked-commit-authors: ""
# File Checks
allowed-file-extensions: ""
allowed-paths: ""
blocked-paths: |
README.md
SECURITY.md
LICENSE
CODE_OF_CONDUCT.md
templates/service-templates-latest.json
templates/service-templates.json
require-final-newline: true
max-added-comments: 10
# User Checks
detect-spam-usernames: true
min-account-age: 30
max-daily-forks: 7
min-profile-completeness: 4
# Merge Checks
min-repo-merged-prs: 0
min-repo-merge-ratio: 0
min-global-merge-ratio: 30
global-merge-ratio-exclude-own: false
# Exemptions
exempt-draft-prs: false
exempt-bots: |
actions-user
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-users: ""
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-label: "quality/exempt"
exempt-pr-label: ""
exempt-all-milestones: false
exempt-all-pr-milestones: false
exempt-milestones: ""
exempt-pr-milestones: ""
# PR Success Actions
success-add-pr-labels: ""
# PR Failure Actions
failure-remove-pr-labels: ""
failure-remove-all-pr-labels: true
failure-add-pr-labels: "quality/rejected"
failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know."
close-pr: true
lock-pr: false

View file

@ -60,7 +60,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
*
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control
### Big Sponsors
@ -151,6 +151,10 @@ ### Small Sponsors
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
<a href="https://interviewpal.com/?utm_source=coolify.io"><img width="60px" alt="InterviewPal" src="/public/svgs/interviewpal.svg"/></a>
<a href="https://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.png"/></a>
<a href="https://youstable.com/?utm_source=coolify.io"><img width="60px" alt="YouStable" src="https://github.com/youstable.png"/></a>
<a href="https://github.com/mindedtech?utm_source=coolify.io"><img width="60px" alt="MindedTech" src="https://github.com/mindedtech.png"/></a>
<a href="https://netrouting.com/?utm_source=coolify.io"><img width="60px" alt="NetRouting" src="https://github.com/netroutingcom.png"/></a>
<a href="https://github.com/parsecph?utm_source=coolify.io"><img width="60px" alt="ParsecPH" src="https://github.com/parsecph.png"/></a>
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)

View file

@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,

View file

@ -107,7 +107,7 @@ public function handle(StandaloneDragonfly $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,

View file

@ -109,7 +109,7 @@ public function handle(StandaloneKeydb $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
@ -166,7 +166,7 @@ public function handle(StandaloneKeydb $database)
$docker_compose['volumes'] = $volume_names;
}
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[

View file

@ -175,7 +175,7 @@ public function handle(StandaloneMariadb $database)
);
}
if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
[

View file

@ -340,7 +340,10 @@ private function add_custom_mongo_conf()
private function add_default_database()
{
$content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
$dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";

View file

@ -175,7 +175,7 @@ public function handle(StandaloneMysql $database)
);
}
if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
@ -215,7 +215,8 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
$mysqlUser = escapeshellarg($this->database->mysql_user);
$this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
}
$this->commands[] = "echo 'Database started.'";

View file

@ -111,10 +111,7 @@ public function handle(StandalonePostgresql $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => [
'CMD-SHELL',
"psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
],
'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
@ -227,7 +224,8 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
$postgresUser = escapeshellarg($this->database->postgres_user);
$this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
}
$this->commands[] = "echo 'Database started.'";
@ -304,9 +302,18 @@ private function generate_init_scripts()
foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content');
// Normalise filename without rejecting legacy values so previously created
// init scripts keep deploying. basename() strips any directory components
// (path traversal) and escapeshellarg() contains every shell metacharacter
// in the tee target. Livewire / API validate new filenames up front.
$filename = basename((string) $filename);
$target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
$escaped_target = escapeshellarg($target_path);
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
$this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null";
$this->init_scripts[] = $target_path;
}
}

View file

@ -181,7 +181,7 @@ public function handle(StandaloneRedis $database)
);
}
if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind',
'source' => $this->configuration_dir.'/redis.conf',

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 = (new User)->forceFill([
@ -49,7 +58,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

@ -48,7 +48,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
);
$commands = [
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",

View file

@ -30,7 +30,8 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
// MapleDeploy branding: Sentinel is not mirrored to our Forgejo registry, so pull from ghcr.io directly (upstream image)
$image = 'ghcr.io/coollabsio/sentinel:'.$version;
if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.');
}

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

@ -88,6 +88,14 @@ private function processFile(string $file): false|array
$payload['envs'] = base64_encode($envFileContent);
}
if (str($data->get('amd_only'))->toBoolean()) {
$payload['amd_only'] = true;
}
if (str($data->get('arm_only'))->toBoolean()) {
$payload['arm_only'] = true;
}
return $payload;
}
@ -160,6 +168,14 @@ private function processFileWithFqdn(string $file): false|array
$payload['envs'] = base64_encode($modifiedEnvContent);
}
if (str($data->get('amd_only'))->toBoolean()) {
$payload['amd_only'] = true;
}
if (str($data->get('arm_only'))->toBoolean()) {
$payload['arm_only'] = true;
}
return $payload;
}
@ -229,6 +245,14 @@ private function processFileWithFqdnRaw(string $file): false|array
$payload['envs'] = $modifiedEnvContent;
}
if (str($data->get('amd_only'))->toBoolean()) {
$payload['amd_only'] = true;
}
if (str($data->get('arm_only'))->toBoolean()) {
$payload['arm_only'] = true;
}
return $payload;
}
}

View file

@ -264,15 +264,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

@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command
public function handle()
{
$date = $this->option('date') ?: now()->format('Y-m-d');
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
return self::INVALID;
}
$logPaths = $this->getLogPaths($date);
if (empty($logPaths)) {
@ -49,17 +54,19 @@ public function handle()
$this->line('');
if (count($logPaths) === 1) {
$logPath = $logPaths[0];
$logPath = escapeshellarg($logPaths[0]);
if ($filters) {
passthru("tail -f {$logPath} | grep -E '{$filters}'");
$escapedFilters = escapeshellarg($filters);
passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPath}");
}
} else {
// Multiple files - use multitail or tail with process substitution
$logPathsStr = implode(' ', $logPaths);
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
$escapedFilters = escapeshellarg($filters);
passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPathsStr}");
}
@ -68,20 +75,23 @@ public function handle()
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
$this->line('');
$escapedLines = escapeshellarg((string) $lines);
if (count($logPaths) === 1) {
$logPath = $logPaths[0];
$logPath = escapeshellarg($logPaths[0]);
if ($filters) {
passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
$escapedFilters = escapeshellarg($filters);
passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
} else {
passthru("tail -n {$lines} {$logPath}");
passthru("tail -n {$escapedLines} {$logPath}");
}
} else {
// Multiple files - concatenate and sort by timestamp
$logPathsStr = implode(' ', $logPaths);
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
$escapedFilters = escapeshellarg($filters);
passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
} else {
passthru("tail -n {$lines} {$logPathsStr} | sort");
passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
}
}
}

View file

@ -2,6 +2,7 @@
namespace App\Console;
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (isDev()) {
// Instance Jobs

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
use App\Actions\Service\StartService;
@ -9,6 +10,7 @@
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
@ -217,7 +219,7 @@ public function applications(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@ -383,7 +385,7 @@ public function create_public_application(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@ -549,7 +551,7 @@ public function create_private_gh_app_application(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@ -1058,7 +1060,7 @@ private function create_application(Request $request, $type)
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@ -2397,7 +2399,7 @@ public function delete_by_uuid(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@ -4119,7 +4121,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);
@ -4297,7 +4299,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@ -4474,4 +4476,73 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']);
}
#[OA\Delete(
summary: 'Delete Preview Deployment',
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.',
path: '/applications/{uuid}/previews/{pull_request_id}',
operationId: 'delete-preview-deployment-by-pull-request-id',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'pull_request_id',
in: 'path',
description: 'Pull request ID of the preview to delete.',
required: true,
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_preview_by_pull_request_id(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('delete', $application);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$pullRequestId = (int) $pullRequestIdRaw;
$preview = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', $pullRequestId)
->first();
if (! $preview) {
return response()->json(['message' => 'Preview not found.'], 404);
}
$preview->delete();
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
return response()->json(['message' => 'Preview deletion request queued.']);
}
}

View file

@ -379,9 +379,9 @@ public function update_by_uuid(Request $request)
case 'standalone-postgresql':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$validator = customApiValidator($request->all(), [
'postgres_user' => 'string',
'postgres_password' => 'string',
'postgres_db' => 'string',
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string',
@ -410,20 +410,20 @@ public function update_by_uuid(Request $request)
case 'standalone-clickhouse':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
break;
case 'standalone-dragonfly':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$validator = customApiValidator($request->all(), [
'dragonfly_password' => 'string',
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
break;
case 'standalone-redis':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$validator = customApiValidator($request->all(), [
'redis_password' => 'string',
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
]);
if ($request->has('redis_conf')) {
@ -450,7 +450,7 @@ public function update_by_uuid(Request $request)
case 'standalone-keydb':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$validator = customApiValidator($request->all(), [
'keydb_password' => 'string',
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
]);
if ($request->has('keydb_conf')) {
@ -478,10 +478,10 @@ public function update_by_uuid(Request $request)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string',
'mariadb_root_password' => 'string',
'mariadb_user' => 'string',
'mariadb_password' => 'string',
'mariadb_database' => 'string',
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
if ($request->has('mariadb_conf')) {
if (! isBase64Encoded($request->mariadb_conf)) {
@ -508,9 +508,9 @@ public function update_by_uuid(Request $request)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
'mongo_initdb_root_username' => 'string',
'mongo_initdb_root_password' => 'string',
'mongo_initdb_database' => 'string',
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
if ($request->has('mongo_conf')) {
if (! isBase64Encoded($request->mongo_conf)) {
@ -537,10 +537,10 @@ public function update_by_uuid(Request $request)
case 'standalone-mysql':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string',
'mysql_password' => 'string',
'mysql_user' => 'string',
'mysql_database' => 'string',
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string',
]);
if ($request->has('mysql_conf')) {
@ -747,7 +747,7 @@ public function create_backup(Request $request)
}
if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@ -774,7 +774,7 @@ public function create_backup(Request $request)
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@ -982,7 +982,7 @@ public function update_backup(Request $request)
], 422);
}
if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@ -1015,7 +1015,7 @@ public function update_backup(Request $request)
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@ -1724,9 +1724,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($type === NewDatabaseTypes::POSTGRESQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$validator = customApiValidator($request->all(), [
'postgres_user' => 'string',
'postgres_password' => 'string',
'postgres_db' => 'string',
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string',
@ -1766,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('postgres_conf', $postgresConf);
}
$database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -1783,8 +1783,11 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
'mariadb_conf' => 'string',
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@ -1821,7 +1824,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('mariadb_conf', $mariadbConf);
}
$database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -1839,10 +1842,10 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string',
'mysql_password' => 'string',
'mysql_user' => 'string',
'mysql_database' => 'string',
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -1880,7 +1883,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('mysql_conf', $mysqlConf);
}
$database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -1898,7 +1901,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$validator = customApiValidator($request->all(), [
'redis_password' => 'string',
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -1936,7 +1939,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('redis_conf', $redisConf);
}
$database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -1954,7 +1957,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$validator = customApiValidator($request->all(), [
'dragonfly_password' => 'string',
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -1973,7 +1976,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
removeUnnecessaryFieldsFromRequest($request);
$database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -1984,7 +1987,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::KEYDB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$validator = customApiValidator($request->all(), [
'keydb_password' => 'string',
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -2022,7 +2025,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('keydb_conf', $keydbConf);
}
$database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -2040,8 +2043,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@ -2058,7 +2061,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
], 422);
}
removeUnnecessaryFieldsFromRequest($request);
$database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -2077,9 +2080,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
'mongo_initdb_root_username' => 'string',
'mongo_initdb_root_password' => 'string',
'mongo_initdb_database' => 'string',
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@ -2116,7 +2119,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('mongo_conf', $mongoConf);
}
$database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields));
$database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@ -2332,7 +2335,7 @@ public function delete_backup_by_uuid(Request $request)
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
return response()->json(['message' => 'Failed to delete backup.'], 500);
}
}
@ -2452,7 +2455,7 @@ public function delete_execution_by_uuid(Request $request)
'message' => 'Backup execution deleted.',
]);
} catch (\Exception $e) {
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
return response()->json(['message' => 'Failed to delete backup execution.'], 500);
}
}
@ -3496,7 +3499,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@ -3694,7 +3697,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);

View file

@ -121,7 +121,7 @@ public function locations(Request $request)
return response()->json($locations);
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
}
}
@ -242,7 +242,7 @@ public function serverTypes(Request $request)
return response()->json($serverTypes);
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
}
}
@ -354,7 +354,7 @@ public function images(Request $request)
return response()->json(array_values($filtered));
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
}
}
@ -450,7 +450,7 @@ public function sshKeys(Request $request)
return response()->json($sshKeys);
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
}
}
@ -733,7 +733,7 @@ public function createServer(Request $request)
return $response;
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
}
}
}

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

@ -221,7 +221,7 @@ public function services(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
@ -843,7 +843,7 @@ public function delete_by_uuid(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
@ -2018,7 +2018,7 @@ public function create_storage(Request $request): JsonResponse
'resource_uuid' => 'required|string',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@ -2227,7 +2227,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);

View file

@ -6,8 +6,8 @@
use App\Models\TeamInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
@ -39,9 +39,29 @@ public function verify()
return view('auth.verify-email');
}
public function email_verify(EmailVerificationRequest $request)
public function email_verify(Request $request)
{
$request->fulfill();
if (! $request->hasValidSignature()) {
abort(403);
}
$user = auth()->user();
if (! $user) {
abort(403);
}
if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
abort(403);
}
if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
abort(403);
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
}
return redirect(RouteServiceProvider::HOME);
}
@ -94,10 +114,6 @@ public function link()
} else {
$team = $user->teams()->first();
}
if (is_null(data_get($user, 'email_verified_at'))) {
$user->email_verified_at = now();
$user->save();
}
Auth::login($user);
session(['currentTeam' => $team]);

View file

@ -11,6 +11,26 @@
class UploadController extends BaseController
{
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
private const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
];
public function upload(Request $request)
{
$databaseIdentifier = request()->route('databaseUuid');
@ -18,6 +38,22 @@ public function upload(Request $request)
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
$chunk = $request->file('file');
$originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
return response()->json([
'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS),
], 422);
}
$declaredTotalSize = (int) $request->input('dzTotalFilesize', 0);
if ($declaredTotalSize > self::MAX_BYTES) {
return response()->json([
'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.',
], 422);
}
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) {
@ -40,29 +76,20 @@ public function upload(Request $request)
'status' => true,
]);
}
// protected function saveFileToS3($file)
// {
// $fileName = $this->createFilename($file);
// $disk = Storage::disk('s3');
// // It's better to use streaming Streaming (laravel 5.4+)
// $disk->putFileAs('photos', $file, $fileName);
// // for older laravel
// // $disk->put($fileName, file_get_contents($file), 'public');
// $mime = str_replace('/', '-', $file->getMimeType());
// // We need to delete the file when uploaded to s3
// unlink($file->getPathname());
// return response()->json([
// 'path' => $disk->url($fileName),
// 'name' => $fileName,
// 'mime_type' => $mime
// ]);
// }
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
@unlink($file->getPathname());
return response()->json([
'error' => 'Uploaded file failed validation.',
], 422);
}
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath);
@ -73,13 +100,30 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier)
]);
}
protected function createFilename(UploadedFile $file)
private static function hasAllowedExtension(string $name): bool
{
$extension = $file->getClientOriginalExtension();
$filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
$lower = strtolower($name);
$suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
$filename .= '_'.md5(time()).'.'.$extension;
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
return $filename;
$stem = substr($lower, 0, -strlen($suffix));
if ($stem !== '' && ! str_ends_with($stem, '.')) {
return true;
}
return false;
}
return false;
}
private static function formatMaxSize(): string
{
return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB';
}
}

View file

@ -57,10 +57,29 @@ public function manual(Request $request)
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
$payload = $request->getContent();
[$algo, $hash] = explode('=', $x_bitbucket_token, 2);
$payloadHash = hash_hmac($algo, $payload, $webhook_secret);
$parts = explode('=', $x_bitbucket_token, 2);
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue;
}
$hash = $parts[1];
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
$return_payloads->push([
'application' => $application->name,

View file

@ -67,6 +67,15 @@ public function manual(Request $request)
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([

View file

@ -81,6 +81,15 @@ public function manual(Request $request)
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([

View file

@ -100,7 +100,16 @@ public function manual(Request $request)
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',

View file

@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use App\Models\PersonalAccessToken;
use App\Models\Team;
use App\Models\User;
use App\Notifications\ApiTokenExpiringNotification;
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\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function handle(): void
{
PersonalAccessToken::query()
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->where('expires_at', '<=', now()->addDay())
->where('tokenable_type', User::class)
->chunkById(100, function ($tokens) {
foreach ($tokens as $token) {
if (! $token->team_id) {
continue;
}
RateLimiter::attempt(
'api-token-expiring:'.$token->id,
$maxAttempts = 0,
function () use ($token) {
Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
},
$decaySeconds = 7 * 24 * 3600,
);
}
});
}
}

View file

@ -2877,7 +2877,7 @@ private function generate_healthcheck_commands()
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
$path = $this->application->health_check_path
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));

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

@ -43,27 +43,34 @@ public function handle()
protected function cloneLocalVolume()
{
$srcVol = escapeshellarg($this->sourceVolume);
$tgtVol = escapeshellarg($this->targetVolume);
instant_remote_process([
"docker volume create $this->targetVolume",
"docker run --rm -v $this->sourceVolume:/source -v $this->targetVolume:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
"docker volume create {$tgtVol}",
"docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
], $this->sourceServer);
}
protected function cloneRemoteVolume()
{
$srcVol = escapeshellarg($this->sourceVolume);
$tgtVol = escapeshellarg($this->targetVolume);
$sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}";
$targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}";
$srcDir = escapeshellarg($sourceCloneDir);
$tgtDir = escapeshellarg($targetCloneDir);
try {
instant_remote_process([
"mkdir -p $sourceCloneDir",
"chmod 777 $sourceCloneDir",
"docker run --rm -v $this->sourceVolume:/source -v $sourceCloneDir:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
"mkdir -p {$srcDir}",
"chmod 777 {$srcDir}",
"docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
], $this->sourceServer);
instant_remote_process([
"mkdir -p $targetCloneDir",
"chmod 777 $targetCloneDir",
"mkdir -p {$tgtDir}",
"chmod 777 {$tgtDir}",
], $this->targetServer);
instant_scp(
@ -74,8 +81,8 @@ protected function cloneRemoteVolume()
);
instant_remote_process([
"docker volume create $this->targetVolume",
"docker run --rm -v $this->targetVolume:/target -v $targetCloneDir:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
"docker volume create {$tgtVol}",
"docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
], $this->targetServer);
} catch (\Exception $e) {
@ -84,7 +91,7 @@ protected function cloneRemoteVolume()
} finally {
try {
instant_remote_process([
"rm -rf $sourceCloneDir",
"rm -rf {$srcDir}",
], $this->sourceServer, false);
} catch (\Exception $e) {
\Log::warning('Failed to clean up source server clone directory: '.$e->getMessage());
@ -93,7 +100,7 @@ protected function cloneRemoteVolume()
try {
if ($this->targetServer) {
instant_remote_process([
"rm -rf $targetCloneDir",
"rm -rf {$tgtDir}",
], $this->targetServer, false);
}
} catch (\Exception $e) {

View file

@ -37,7 +37,7 @@ public function back()
Auth::login($user);
refreshSession($team_to_switch_to);
return redirect(request()->header('Referer'));
return redirect()->route('admin.index');
}
}
@ -70,7 +70,7 @@ public function switchUser(int $user_id)
Auth::login($user);
refreshSession($team_to_switch_to);
return redirect(request()->header('Referer'));
return redirect()->route('dashboard');
}
private function authorizeAdminAccess(): void

View file

@ -2,9 +2,7 @@
namespace App\Livewire\Destination;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@ -29,16 +27,8 @@ class Show extends Component
public function mount(string $destination_uuid)
{
try {
$destination = StandaloneDocker::whereUuid($destination_uuid)->first() ??
SwarmDocker::whereUuid($destination_uuid)->firstOrFail();
$ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) {
if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) {
$this->destination = $destination;
$this->syncData();
}
});
if ($ownedByTeam === false) {
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
return redirect()->route('destination.index');
}
$this->destination = $destination;
@ -80,7 +70,7 @@ public function delete()
try {
$this->authorize('delete', $this->destination);
if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
if ($this->destination->getMorphClass() === StandaloneDocker::class) {
if ($this->destination->attachedTo()) {
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
}

View file

@ -1496,7 +1496,10 @@ public function getServicesProperty()
'category' => 'Services',
'resourceType' => 'service',
'logo' => data_get($service, 'logo'),
]);
] + array_filter([
'amd_only' => data_get($service, 'amd_only') ? true : null,
'arm_only' => data_get($service, 'arm_only') ? true : null,
]));
}
$cachedServices = $items->toArray();

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

@ -197,12 +197,12 @@ protected function messages(): array
'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.',
'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'installCommand.regex' => 'The install command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'buildCommand.regex' => 'The build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'startCommand.regex' => 'The start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'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.',

View file

@ -76,8 +76,12 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'clickhouseAdminUser' => 'required|string',
'clickhouseAdminPassword' => 'required|string',
'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user,
),
'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password,
),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@ -96,10 +100,8 @@ protected function messages(): array
ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(),
[
'clickhouseAdminUser.required' => 'The Admin User field is required.',
'clickhouseAdminUser.string' => 'The Admin User must be a string.',
'clickhouseAdminPassword.required' => 'The Admin Password field is required.',
'clickhouseAdminPassword.string' => 'The Admin Password must be a string.',
...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'),
...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'),
'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.',

View file

@ -89,7 +89,9 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'dragonflyPassword' => 'required|string',
'dragonflyPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password,
),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@ -109,8 +111,7 @@ protected function messages(): array
ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(),
[
'dragonflyPassword.required' => 'The Dragonfly Password field is required.',
'dragonflyPassword.string' => 'The Dragonfly Password must be a string.',
...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'),
'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.',

View file

@ -92,7 +92,9 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string',
'keydbPassword' => 'required|string',
'keydbPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->keydbPassword !== $this->database->keydb_password,
),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@ -114,8 +116,7 @@ protected function messages(): array
ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(),
[
'keydbPassword.required' => 'The KeyDB Password field is required.',
'keydbPassword.string' => 'The KeyDB Password must be a string.',
...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'),
'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.',

View file

@ -74,10 +74,18 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mariadbRootPassword' => 'required',
'mariadbUser' => 'required',
'mariadbPassword' => 'required',
'mariadbDatabase' => 'required',
'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password,
),
'mariadbUser' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->mariadbUser !== $this->database->mariadb_user,
),
'mariadbPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password,
),
'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database,
),
'mariadbConf' => 'nullable',
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
@ -97,10 +105,10 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'mariadbRootPassword.required' => 'The Root Password field is required.',
'mariadbUser.required' => 'The MariaDB User field is required.',
'mariadbPassword.required' => 'The MariaDB Password field is required.',
'mariadbDatabase.required' => 'The MariaDB Database field is required.',
...ValidationPatterns::databasePasswordMessages('mariadbRootPassword', 'Root Password'),
...ValidationPatterns::databaseIdentifierMessages('mariadbUser', 'MariaDB User'),
...ValidationPatterns::databasePasswordMessages('mariadbPassword', 'MariaDB Password'),
...ValidationPatterns::databaseIdentifierMessages('mariadbDatabase', 'MariaDB Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',

View file

@ -75,9 +75,15 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mongoConf' => 'nullable',
'mongoInitdbRootUsername' => 'required',
'mongoInitdbRootPassword' => 'required',
'mongoInitdbDatabase' => 'required',
'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username,
),
'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password,
),
'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database,
),
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@ -97,9 +103,9 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'),
...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'),
...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',

View file

@ -76,10 +76,18 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mysqlRootPassword' => 'required',
'mysqlUser' => 'required',
'mysqlPassword' => 'required',
'mysqlDatabase' => 'required',
'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password,
),
'mysqlUser' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->mysqlUser !== $this->database->mysql_user,
),
'mysqlPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->mysqlPassword !== $this->database->mysql_password,
),
'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database,
),
'mysqlConf' => 'nullable',
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
@ -100,10 +108,10 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'mysqlRootPassword.required' => 'The Root Password field is required.',
'mysqlUser.required' => 'The MySQL User field is required.',
'mysqlPassword.required' => 'The MySQL Password field is required.',
'mysqlDatabase.required' => 'The MySQL Database field is required.',
...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'),
...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'),
...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'),
...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',

View file

@ -86,9 +86,15 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'postgresUser' => 'required',
'postgresPassword' => 'required',
'postgresDb' => 'required',
'postgresUser' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->postgresUser !== $this->database->postgres_user,
),
'postgresPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->postgresPassword !== $this->database->postgres_password,
),
'postgresDb' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->postgresDb !== $this->database->postgres_db,
),
'postgresInitdbArgs' => 'nullable',
'postgresHostAuthMethod' => 'nullable',
'postgresConf' => 'nullable',
@ -112,9 +118,9 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'postgresUser.required' => 'The Postgres User field is required.',
'postgresPassword.required' => 'The Postgres Password field is required.',
'postgresDb.required' => 'The Postgres Database field is required.',
...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'),
...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'),
...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
@ -352,9 +358,14 @@ public function save_init_script($script)
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($oldScript['filename'], 'init script filename');
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
// New filename is user-supplied — must be safe before accepting the rename.
validateFilenameSafe($script['filename'], 'init script filename');
// Old filename may be a legacy value written before this validation existed.
// basename() scopes the rm to the initdb.d directory; escapeshellarg() contains
// any remaining shell-metachars. No validator — don't block cleanup of legacy rows.
$old_filename = basename($oldScript['filename']);
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$old_filename}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
@ -398,9 +409,11 @@ public function delete_init_script($script)
$configuration_dir = database_configuration_dir().'/'.$container_name;
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($script['filename'], 'init script filename');
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
// Allow deletion of legacy rows with unsafe filenames so operators can clean up.
// basename() scopes the rm to the initdb.d directory; escapeshellarg() keeps the
// shell invocation safe regardless of the stored value.
$safe_filename = basename($script['filename']);
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$safe_filename}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
@ -437,8 +450,8 @@ public function save_new_init_script()
]);
try {
// Validate filename to prevent command injection
validateShellSafePath($this->new_filename, 'init script filename');
// Validate filename to prevent path traversal and command injection
validateFilenameSafe($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());

View file

@ -81,8 +81,12 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'redisUsername' => 'required',
'redisPassword' => 'required',
'redisUsername' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->redisUsername !== $this->database->redis_username,
),
'redisPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->redisPassword !== $this->database->redis_password,
),
'enableSsl' => 'boolean',
];
}
@ -100,8 +104,8 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'redisUsername.required' => 'The Redis Username field is required.',
'redisPassword.required' => 'The Redis Password field is required.',
...ValidationPatterns::databaseIdentifierMessages('redisUsername', 'Redis Username'),
...ValidationPatterns::databasePasswordMessages('redisPassword', 'Redis Password'),
]
);
}

View file

@ -5,8 +5,6 @@
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@ -31,7 +29,6 @@ public function mount()
public function submit()
{
$server_id = $this->query['server_id'];
try {
$this->validate([
'dockerComposeRaw' => 'required',
@ -44,20 +41,17 @@ public function submit()
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
$service = Service::create([
'docker_compose_raw' => $this->dockerComposeRaw,
'environment_id' => $environment->id,
'server_id' => (int) $server_id,
'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
]);

View file

@ -4,8 +4,6 @@
use App\Models\Application;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Services\DockerImageParser;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@ -111,13 +109,10 @@ public function submit()
$parser = new DockerImageParser;
$parser->parse($dockerImage);
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();

View file

@ -5,8 +5,6 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http;
@ -178,13 +176,10 @@ public function submit()
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
}
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();

View file

@ -7,8 +7,6 @@
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@ -130,13 +128,10 @@ public function submit()
{
$this->validate();
try {
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();

View file

@ -7,8 +7,6 @@
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@ -34,8 +32,6 @@ class PublicGitRepository extends Component
public bool $isStatic = false;
public bool $checkCoolifyConfig = true;
public ?string $publish_directory = null;
// In case of docker compose
@ -284,16 +280,13 @@ public function submit()
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
}
$destination_uuid = $this->query['destination'];
$destination_uuid = $this->query['destination'] ?? null;
$project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
@ -371,12 +364,6 @@ public function submit()
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;
$application->save();
if ($this->checkCoolifyConfig) {
// $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
// if ($config) {
// $application->setConfig($config);
// }
}
return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid,

View file

@ -5,8 +5,6 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@ -35,13 +33,10 @@ public function submit()
$this->validate([
'dockerfile' => 'required',
]);
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();

View file

@ -4,7 +4,6 @@
use App\Models\EnvironmentVariable;
use App\Models\Service;
use App\Models\StandaloneDocker;
use Livewire\Component;
class Create extends Component
@ -18,7 +17,6 @@ public function mount()
$type = str(request()->query('type'));
$destination_uuid = request()->query('destination');
$server_id = request()->query('server_id');
$database_image = request()->query('database_image');
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();
@ -30,7 +28,11 @@ public function mount()
if (! $environment) {
return redirect()->route('dashboard');
}
if (isset($type) && isset($destination_uuid) && isset($server_id)) {
if (isset($type) && isset($destination_uuid)) {
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
return redirect()->route('dashboard');
}
$services = get_service_templates();
if (in_array($type, DATABASE_TYPES)) {
@ -44,23 +46,23 @@ public function mount()
}
$database = create_standalone_postgresql(
environmentId: $environment->id,
destinationUuid: $destination_uuid,
destination: $destination,
databaseImage: $database_image
);
} elseif ($type->value() === 'redis') {
$database = create_standalone_redis($environment->id, $destination_uuid);
$database = create_standalone_redis($environment->id, $destination);
} elseif ($type->value() === 'mongodb') {
$database = create_standalone_mongodb($environment->id, $destination_uuid);
$database = create_standalone_mongodb($environment->id, $destination);
} elseif ($type->value() === 'mysql') {
$database = create_standalone_mysql($environment->id, $destination_uuid);
$database = create_standalone_mysql($environment->id, $destination);
} elseif ($type->value() === 'mariadb') {
$database = create_standalone_mariadb($environment->id, $destination_uuid);
$database = create_standalone_mariadb($environment->id, $destination);
} elseif ($type->value() === 'keydb') {
$database = create_standalone_keydb($environment->id, $destination_uuid);
$database = create_standalone_keydb($environment->id, $destination);
} elseif ($type->value() === 'dragonfly') {
$database = create_standalone_dragonfly($environment->id, $destination_uuid);
$database = create_standalone_dragonfly($environment->id, $destination);
} elseif ($type->value() === 'clickhouse') {
$database = create_standalone_clickhouse($environment->id, $destination_uuid);
$database = create_standalone_clickhouse($environment->id, $destination);
}
return redirect()->route('project.database.configuration', [
@ -69,7 +71,7 @@ public function mount()
'database_uuid' => $database->uuid,
]);
}
if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) {
if ($type->startsWith('one-click-service-')) {
$oneClickServiceName = $type->after('one-click-service-')->value();
$oneClickService = data_get($services, "$oneClickServiceName.compose");
$oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
@ -79,12 +81,11 @@ public function mount()
});
}
if ($oneClickService) {
$destination = StandaloneDocker::whereUuid($destination_uuid)->first();
$service_payload = [
'docker_compose_raw' => base64_decode($oneClickService),
'environment_id' => $environment->id,
'service_type' => $oneClickServiceName,
'server_id' => (int) $server_id,
'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];

View file

@ -106,8 +106,12 @@ public function submitPersistentVolume()
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
], ValidationPatterns::volumeNameMessages());
'host_path' => $this->isSwarm
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
], array_merge(ValidationPatterns::volumeNameMessages(), [
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
]));
$name = $this->resource->uuid.'-'.$this->name;

View file

@ -34,7 +34,7 @@ class HealthChecks extends Component
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
public ?string $healthCheckPort = null;
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'])]
public string $healthCheckPath;
#[Validate(['integer'])]
@ -62,7 +62,7 @@ class HealthChecks extends Component
'healthCheckEnabled' => 'boolean',
'healthCheckType' => 'string|in:http,cmd',
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',

View file

@ -58,10 +58,9 @@ public function cloneTo($destination_id)
{
$this->authorize('update', $this->resource);
$teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
$new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
$new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
if (! $new_destination) {
$new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
$new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
}
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\LocalPersistentVolume;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -31,19 +32,33 @@ class Show extends Component
public bool $isPreviewSuffixEnabled = true;
protected $rules = [
'name' => 'required|string',
'mountPath' => 'required|string',
'hostPath' => 'string|nullable',
'isPreviewSuffixEnabled' => 'required|boolean',
];
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
'hostPath' => 'host',
];
protected function rules(): array
{
return [
'name' => ValidationPatterns::volumeNameRules(),
'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'isPreviewSuffixEnabled' => 'required|boolean',
];
}
protected function messages(): array
{
return array_merge(
ValidationPatterns::volumeNameMessages(),
[
'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
]
);
}
/**
* Sync data between component properties and model
*

View file

@ -13,10 +13,20 @@ class ApiTokens extends Component
public ?string $description = null;
public ?int $expiresInDays = 30;
public $tokens = [];
public array $permissions = ['read'];
public array $expirationOptions = [
7 => '7 days',
30 => '30 days',
60 => '60 days',
90 => '90 days',
365 => '1 year',
];
public $isApiEnabled;
public bool $canUseRootPermissions = false;
@ -90,8 +100,10 @@ public function addNewToken()
$this->validate([
'description' => 'required|min:3|max:255',
'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
]);
$token = auth()->user()->createToken($this->description, array_values($this->permissions));
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
$this->getTokens();
session()->flash('token', $token->plainTextToken);
} catch (\Exception $e) {

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', new ValidDnsServers],
'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

@ -35,7 +35,7 @@ class Index extends Component
#[Validate('required|string|timezone')]
public string $instance_timezone;
#[Validate('nullable|string|max:50')]
#[Validate(['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/'])]
public ?string $dev_helper_version = null;
public array $domainConflicts = [];
@ -49,6 +49,7 @@ class Index extends Component
protected array $messages = [
'fqdn.url' => 'Invalid instance URL.',
'fqdn.max' => 'URL must not exceed 255 characters.',
'dev_helper_version.regex' => 'Dev helper version must match Docker tag format (alphanumeric, _, ., -; first char cannot be . or -).',
];
public function render()
@ -184,6 +185,8 @@ public function buildHelperImage()
return;
}
$this->validateOnly('dev_helper_version');
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');
@ -191,7 +194,14 @@ public function buildHelperImage()
return;
}
$buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
if (! preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/', (string) $version)) {
$this->dispatch('error', 'Invalid helper version format.');
return;
}
$imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}");
$buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile .";
$activity = remote_process(
command: [$buildCommand],

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri;
@ -37,7 +38,7 @@ protected function rules(): array
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
'endpoint' => 'required|url|max:255',
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@ -55,7 +56,6 @@ protected function messages(): array
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
'endpoint.url' => 'The Endpoint must be a valid URL.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
@ -42,7 +43,7 @@ protected function rules(): array
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
'endpoint' => 'required|url|max:255',
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@ -60,7 +61,6 @@ protected function messages(): array
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
'endpoint.url' => 'The Endpoint must be a valid URL.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);

View file

@ -25,7 +25,9 @@ public function mount(): void
public function disableS3(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$backup = ScheduledDatabaseBackup::where('id', $backupId)
->where('s3_storage_id', $this->storage->id)
->firstOrFail();
$backup->update([
'save_s3' => false,
@ -39,7 +41,9 @@ public function disableS3(int $backupId): void
public function moveBackup(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$backup = ScheduledDatabaseBackup::where('id', $backupId)
->where('s3_storage_id', $this->storage->id)
->firstOrFail();
$newStorageId = $this->selectedStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {

View file

@ -215,14 +215,27 @@ class Application extends BaseModel
protected $appends = ['server_status'];
protected $casts = [
'http_basic_auth_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected function casts(): array
{
return [
'http_basic_auth_password' => 'encrypted',
'manual_webhook_secret_github' => 'encrypted',
'manual_webhook_secret_gitlab' => 'encrypted',
'manual_webhook_secret_bitbucket' => 'encrypted',
'manual_webhook_secret_gitea' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
}
protected static function booted()
{
static::creating(function ($application) {
$application->manual_webhook_secret_github ??= Str::random(40);
$application->manual_webhook_secret_gitlab ??= Str::random(40);
$application->manual_webhook_secret_bitbucket ??= Str::random(40);
$application->manual_webhook_secret_gitea ??= Str::random(40);
});
static::addGlobalScope('withRelations', function ($builder) {
$builder->withCount([
'additional_servers',

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@ -42,11 +43,18 @@ protected static function booted()
$networkKeys = collect($networks)->keys();
$volumeKeys = collect($volumes)->keys();
$volumeKeys->each(function ($key) use ($server) {
instant_remote_process(["docker volume rm -f $key"], $server, false);
if (! preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key)) {
return;
}
instant_remote_process(['docker volume rm -f '.escapeshellarg($key)], $server, false);
});
$networkKeys->each(function ($key) use ($server) {
instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false);
instant_remote_process(["docker network rm $key"], $server, false);
if (! preg_match(ValidationPatterns::DOCKER_NETWORK_PATTERN, $key)) {
return;
}
$k = escapeshellarg($key);
instant_remote_process(["docker network disconnect {$k} coolify-proxy"], $server, false);
instant_remote_process(["docker network rm {$k}"], $server, false);
});
} else {
// Regular application volume cleanup

View file

@ -2,11 +2,13 @@
namespace App\Models;
use App\Rules\SafeWebhookUrl;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
class S3Storage extends BaseModel
{
@ -66,6 +68,13 @@ public static function ownedByCurrentTeam(array $select = ['*'])
return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name');
}
public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name');
}
public function isUsable()
{
return $this->is_usable;
@ -132,6 +141,14 @@ protected function region(): Attribute
public function testConnection(bool $shouldSave = false)
{
try {
$validator = Validator::make(
['endpoint' => $this['endpoint']],
['endpoint' => ['required', new SafeWebhookUrl]],
);
if ($validator->fails()) {
throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
}
$disk = Storage::build([
'driver' => 's3',
'region' => $this['region'],

View file

@ -90,6 +90,16 @@ public function server()
return $this->belongsTo(Server::class);
}
public static function ownedByCurrentTeam()
{
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
}
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.

View file

@ -71,6 +71,16 @@ public function server()
return $this->belongsTo(Server::class);
}
public static function ownedByCurrentTeam()
{
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
}
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.

View file

@ -233,6 +233,9 @@ public function subscriptionEnded()
'is_reachable' => false,
]);
ServerReachabilityChanged::dispatch($server);
$server->unreachable_count = 3;
$server->unreachable_notification_sent = true;
$server->save();
}
}
@ -344,5 +347,4 @@ public function webhookNotificationSettings()
{
return $this->hasOne(WebhookNotificationSettings::class);
}
}

View file

@ -92,7 +92,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;
@ -196,7 +196,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;
@ -257,7 +257,7 @@ public function sendVerificationEmail()
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $this->getKey(),
'hash' => sha1($this->getEmailForVerification()),
'hash' => hash('sha256', $this->getEmailForVerification()),
]
);
$mail->view('emails.email-verification', [

View file

@ -0,0 +1,103 @@
<?php
namespace App\Notifications;
use App\Models\PersonalAccessToken;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class ApiTokenExpiringNotification extends CustomEmailNotification
{
protected string $tokenName;
protected string $expiresAt;
protected string $manageUrl;
public function __construct(public PersonalAccessToken $token)
{
$this->onQueue('high');
$this->tokenName = $token->name;
$this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? '';
$this->manageUrl = route('security.api-tokens');
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('api_token_expiring');
}
public function toMail(): MailMessage
{
$mail = new MailMessage;
$mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours");
$mail->view('emails.api-token-expiring', [
'tokenName' => $this->tokenName,
'expiresAt' => $this->expiresAt,
'manageUrl' => $this->manageUrl,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
$message = new DiscordMessage(
title: '🔑 API token expiring soon',
description: "API token **{$this->tokenName}** expires on {$this->expiresAt}.\n\n**Action Required:** Rotate this token before it expires to avoid API outages.",
color: DiscordMessage::warningColor(),
);
$message->addField('Manage tokens', "[Open Security settings]({$this->manageUrl})");
return $message;
}
public function toTelegram(): array
{
$message = "Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}.\n\nAction Required: Rotate this token before it expires to avoid API outages.";
return [
'message' => $message,
'buttons' => [
[
'text' => 'Manage API tokens',
'url' => $this->manageUrl,
],
],
];
}
public function toPushover(): PushoverMessage
{
$message = "API token <b>{$this->tokenName}</b> expires on {$this->expiresAt}.<br/><br/>";
$message .= '<b>Action Required:</b> Rotate this token before it expires to avoid API outages.';
return new PushoverMessage(
title: 'API token expiring soon',
level: 'warning',
message: $message,
buttons: [
[
'text' => 'Manage API tokens',
'url' => $this->manageUrl,
],
],
);
}
public function toSlack(): SlackMessage
{
$description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n";
$description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n";
$description .= "Manage tokens: {$this->manageUrl}";
return new SlackMessage(
title: '🔑 API token expiring soon',
description: $description,
color: SlackMessage::warningColor(),
);
}
}

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,5 +54,9 @@ protected function configureRateLimiting(): void
RateLimiter::for('5', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('feedback', function (Request $request) {
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
});
}
}

View file

@ -40,9 +40,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$host = strtolower($host);
// Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
// literals can't sneak past filter_var FILTER_VALIDATE_IP.
$hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($host, $blockedHosts) || str_ends_with($host, '.internal')) {
if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
@ -55,7 +61,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) {
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,

View file

@ -36,15 +36,31 @@ class ValidationPatterns
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
* Allows & for command chaining (&&) which is common in multi-step build commands
* Allows double quotes for build args with spaces (e.g. --build-arg KEY="value")
* Blocks backslashes to prevent escape-sequence attacks
* Allows single and double quotes for quoted arguments (e.g. --entrypoint "sh -c 'npm start'")
* Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
* Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
*
* Accepts a sequence of the following tokens only:
* [ \t]+ whitespace (space / tab)
* && logical AND (matched before bare & can match anything)
* || logical OR (matched before bare | can match anything)
* "[^"$`\\\n\r]*" — balanced double-quoted string; blocks $, backtick, \, newlines inside
* '[^'\n\r]*' balanced single-quoted string; blocks newlines inside (all else literal)
* [safe-chars]+ unquoted alphanumerics + safe path/arg chars (includes glob *, ?, and !)
*
* Blocked everywhere (outside and inside unquoted tokens):
* bare & (background op), bare |, ;, $, `, (, ), <, >, \, newline, CR
*
* Blocked inside double-quoted spans specifically:
* $ (variable/command expansion), ` (command substitution), \ (escape)
*
* Legitimate use cases preserved:
* docker compose build && docker tag x && docker push y
* make build || make clean
* rm *.tmp cp src/?.js dist/
* ! grep -q foo && echo missing
* docker compose up -d --build-arg VERSION="1.0.0"
* --entrypoint "sh -c 'npm start'"
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"\']+$/';
public const SHELL_SAFE_COMMAND_PATTERN = '/^(?:[ \t]+|&&|\|\||"[^"$`\\\\\n\r]*"|\'[^\'\n\r]*\'|[a-zA-Z0-9._\-\/=:@,+\[\]{}#%^~*?!]+)+$/';
/**
* Pattern for Docker volume names
@ -66,6 +82,112 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
* Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit).
*/
public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/';
/**
* Pattern for database passwords.
* Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null.
* Allows a broad set of printable characters so passwords remain strong.
*/
public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
/**
* Get validation rules for database identifier fields (username, database name).
*
* Set $enforcePattern to false to skip the regex check (for example when
* re-validating a legacy value on an existing record that has not been
* changed by the user). The length and type rules are always applied.
*/
public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "min:$minLength";
$rules[] = "max:$maxLength";
if ($enforcePattern) {
$rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN;
}
return $rules;
}
/**
* Get validation messages for database identifier fields.
*/
public static function databaseIdentifierMessages(string $field, string $label = ''): array
{
$label = $label ?: $field;
return [
"{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.",
"{$field}.min" => "The {$label} must be at least :min character.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Get validation rules for database password fields.
*
* Set $enforcePattern to false to skip the regex check (for example when
* re-validating a legacy value on an existing record that has not been
* changed by the user). The length and type rules are always applied.
*/
public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "min:$minLength";
$rules[] = "max:$maxLength";
if ($enforcePattern) {
$rules[] = 'regex:'.self::DB_PASSWORD_PATTERN;
}
return $rules;
}
/**
* Get validation messages for database password fields.
*/
public static function databasePasswordMessages(string $field, string $label = ''): array
{
$label = $label ?: $field;
return [
"{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).",
"{$field}.min" => "The {$label} must be at least :min character.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Check if a string is a valid database identifier.
*/
public static function isValidDatabaseIdentifier(string $value): bool
{
return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1;
}
/**
* Get validation rules for name fields
*/

View file

@ -19,6 +19,7 @@ trait HasNotificationSettings
'test',
'ssl_certificate_renewal',
'hetzner_deletion_failure',
'api_token_expiring',
];
/**

View file

@ -106,7 +106,7 @@ function sharedDataApplications()
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',

View file

@ -3,6 +3,7 @@
use App\Models\EnvironmentVariable;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
@ -12,18 +13,19 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
{
$destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
$database = new StandalonePostgresql;
$database->uuid = (new Cuid2);
$database->name = 'postgresql-database-'.$database->uuid;
$database->image = $databaseImage;
$database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->postgres_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environmentId;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $
return $database;
}
function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneRedis;
$database->uuid = (new Cuid2);
$database->name = 'redis-database-'.$database->uuid;
$redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$redis_password = Str::password(length: 64, symbols: false);
if ($otherData && isset($otherData['redis_password'])) {
$redis_password = $otherData['redis_password'];
unset($otherData['redis_password']);
@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
return $database;
}
function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMongodb;
$database->uuid = (new Cuid2);
$database->name = 'mongodb-database-'.$database->uuid;
$database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o
return $database;
}
function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMysql;
$database->uuid = (new Cuid2);
$database->name = 'mysql-database-'.$database->uuid;
$database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mysql_root_password = Str::password(length: 64, symbols: false);
$database->mysql_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth
return $database;
}
function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMariadb;
$database->uuid = (new Cuid2);
$database->name = 'mariadb-database-'.$database->uuid;
$database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mariadb_root_password = Str::password(length: 64, symbols: false);
$database->mariadb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
return $database;
}
function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneKeydb;
$database->uuid = (new Cuid2);
$database->name = 'keydb-database-'.$database->uuid;
$database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->keydb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth
return $database;
}
function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneDragonfly;
$database->uuid = (new Cuid2);
$database->name = 'dragonfly-database-'.$database->uuid;
$database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->dragonfly_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array
return $database;
}
function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneClickhouse;
$database->uuid = (new Cuid2);
$database->name = 'clickhouse-database-'.$database->uuid;
$database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@ -279,7 +274,7 @@ function removeOldBackups($backup): void
->whereNull('s3_uploaded')
->delete();
} catch (\Exception $e) {
} catch (Exception $e) {
throw $e;
}
}
@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection
$processedBackups = collect();
$server = null;
if ($backup->database_type === \App\Models\ServiceDatabase::class) {
if ($backup->database_type === ServiceDatabase::class) {
$server = $backup->database->service->server;
} else {
$server = $backup->database->destination->server;

View file

@ -18,6 +18,7 @@
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
@ -25,6 +26,7 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Carbon\CarbonImmutable;
@ -155,6 +157,73 @@ function validateShellSafePath(string $input, string $context = 'path'): string
return $input;
}
/**
* Validate that a filename is safe for use as a plain file name (no path components).
*
* Prevents path traversal attacks by rejecting directory separators, traversal
* sequences, and null bytes, in addition to all shell metacharacters blocked by
* validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL
* init script names that are later written to a specific directory on the host.
*
* @param string $input The filename to validate
* @param string $context Descriptive name for error messages (e.g., 'init script filename')
* @return string The validated input (unchanged if valid)
*
* @throws Exception If dangerous characters or path traversal sequences are detected
*/
function validateFilenameSafe(string $input, string $context = 'filename'): string
{
// First apply shell-metachar checks
validateShellSafePath($input, $context);
// Reject NUL bytes (can be used to truncate path strings in some contexts)
if (str_contains($input, "\0")) {
throw new Exception(
"Invalid {$context}: contains null byte. ".
'Null bytes are not allowed in filenames for security reasons.'
);
}
// Reject directory separators — filename must be a single path component
if (str_contains($input, '/') || str_contains($input, '\\')) {
throw new Exception(
"Invalid {$context}: directory separators ('/' or '\\') are not allowed. ".
'Provide a plain filename without path components.'
);
}
// Reject path traversal sequences (catches encoded or unusual forms)
if (str_contains($input, '..')) {
throw new Exception(
"Invalid {$context}: path traversal sequence ('..') is not allowed."
);
}
// Reject shell globbing / expansion metacharacters and whitespace that would
// split the filename into additional shell arguments if ever interpolated
// unquoted (defence in depth on top of escapeshellarg() at call sites).
$shellExpansionChars = [
' ' => 'whitespace',
'*' => 'glob wildcard',
'?' => 'glob wildcard',
'[' => 'glob character class',
']' => 'glob character class',
'~' => 'tilde expansion',
'"' => 'double quote',
"'" => 'single quote',
];
foreach ($shellExpansionChars as $char => $description) {
if (str_contains($input, $char)) {
throw new Exception(
"Invalid {$context}: contains forbidden character '{$char}' ({$description})."
);
}
}
return $input;
}
/**
* Validate that a databases_to_backup input string is safe from command injection.
*
@ -259,6 +328,16 @@ function currentTeam()
return Auth::user()?->currentTeam() ?? null;
}
function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
{
if (blank($uuid) || ! currentTeam()) {
return null;
}
return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first()
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
}
function showBoarding(): bool
{
if (isDev()) {
@ -331,13 +410,14 @@ function get_route_parameters(): array
function get_latest_sentinel_version(): string
{
// MapleDeploy branding: our versions.json omits the sentinel key, so fall back to 'latest' (matches upstream's fallback in CheckAndStartSentinelJob)
try {
$response = Http::get(config('constants.coolify.versions_url'));
$versions = $response->json();
return data_get($versions, 'coolify.sentinel.version');
} catch (Throwable) {
return '0.0.0';
return data_get($versions, 'coolify.sentinel.version') ?? 'latest';
} catch (\Throwable) {
return 'latest';
}
}
function get_latest_version_of_coolify(): string
@ -3453,10 +3533,10 @@ function wireNavigate(): string
try {
$settings = instanceSettings();
// Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
// Return wire:navigate for SPA navigation with prefetching, or empty string if disabled
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : '';
} catch (Exception $e) {
return 'wire:navigate.hover';
return 'wire:navigate';
}
}
@ -3489,34 +3569,6 @@ function getHelperVersion(): string
return config('constants.coolify.helper_version');
}
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
{
$server = Server::find($server_id)->where('team_id', $team_id)->first();
if (! $server) {
return;
}
$uuid = new Cuid2;
$cloneCommand = "git clone --no-checkout -b $branch $repository .";
$workdir = rtrim($base_directory, '/');
$fileList = collect([".$workdir/coolify.json"]);
$commands = collect([
"rm -rf /tmp/{$uuid}",
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir/coolify.json",
'rm -rf /tmp/{$uuid}',
]);
try {
return instant_remote_process($commands, $server);
} catch (Exception) {
// continue
}
}
function loggy($message = null, array $context = [])
{
if (! isDev()) {
@ -3660,13 +3712,21 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
}
}
preg_match('/(?<=:)\d+(?=\/)/', $gitRepository, $matches);
$normalizedRepository = $repository;
if (count($matches) === 1) {
$providerInfo['port'] = $matches[0];
$gitHost = str($gitRepository)->before(':');
$gitRepo = str($gitRepository)->after('/');
$repository = "$gitHost:$gitRepo";
if (str($normalizedRepository)->contains('://')) {
$parsedRepository = parse_url($normalizedRepository);
if ($parsedRepository !== false && array_key_exists('port', $parsedRepository)) {
$providerInfo['port'] = (string) $parsedRepository['port'];
}
} else {
preg_match('/^(?<host>[^:]+):(?<port>\d+)\/(?<path>.+)$/', $normalizedRepository, $matches);
if (! empty($matches['port'])) {
$providerInfo['port'] = $matches['port'];
$repository = "{$matches['host']}:{$matches['path']}";
}
}
return [

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.473',
'version' => '4.0.0',
'helper_version' => '1.0.13',
'realtime_version' => '1.0.13',
'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' => [
@ -89,12 +90,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'),
],

5
config/deprecations.php Normal file
View file

@ -0,0 +1,5 @@
<?php
return [
'swarm' => 'Docker Swarm is deprecated and will be removed in Coolify v5. Coolify v5 will be replacing Swarm with native Docker Compose replicas and our own scaling solution. Existing Swarm deployments will continue to work on v4 as-is. We do not recommend setting up new Swarm deployments for the time being.',
];

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