Compare commits

...
Author SHA1 Message Date
Jake Barnby e125bf6aec merge: cross-db alerts persistence fix 2026-05-06 15:26:51 +12:00
Jake BarnbyandClaude Opus 4.7 aa9b45b5a1 fix(notifications): persist alerts to platform db regardless of dispatching project
The `alerts` collection lives in the platform database, but the Notifications
worker was injecting `dbForProject` and writing alerts there. Webhooks
dispatches with the user's project context, so alerts were being written to
the user's project DB (which has no `alerts` collection), and `/v1/account/alerts`
(which reads from `dbForPlatform`) returned nothing.

Switch the worker to inject `dbForPlatform` instead. All alert reads (dedup
lookup) and writes (persistAlert + ConsoleAdapter) now target the platform DB,
independent of which project the dispatching event belongs to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:25:45 +12:00
Jake Barnby 5357e7dd35 fix(notifications): quote $id key in PHPDoc array shape
PHPStan rejects unquoted $id as a variable token in array shape syntax.
Quoting the key satisfies the parser without changing the runtime contract.
2026-05-06 15:24:25 +12:00
Jake Barnby a8ce7c3736 merge: ST17 e2e tests 2026-05-06 15:11:57 +12:00
Jake Barnby 540a7823cd merge: ST16 unit tests 2026-05-06 15:11:54 +12:00
Jake BarnbyandClaude Opus 4.7 534123cd87 test(notifications): add unit tests for dedup, fanout, console skip, zero-delivery, recipient struct, and tracking pixel
Cover the new worker behaviors introduced in waves 1-3:

- testDedupQueriesByAttributeNotById: prove alreadyDelivered() queries the
  messageId attribute, not getDocument($messageId), by seeding a row with
  a non-matching $id but matching messageId.
- testConsoleChannelSkipsPersistAlert: confirm the action loop does NOT
  call persistAlert for console recipients (the adapter persists).
- testConsoleZeroDeliveryThrows: surface adapter failures via the worker.
- testMultiRecipientFanoutNoCollision: same messageId across recipients
  must produce distinct $id values.
- testRecipientStructRoundtripsUserIdAndTeamId: persisted alert carries
  recipient userId/teamId.
- testTrackingPixelInjectedIntoEmailHtml: verify the tracking pixel is
  spliced before the last </body> tag with the expected URL shape.
- testPersistAlertReturnsAlertIdAndStoresUserId: dispatchEmail's
  persistAlert returns a resolvable id and stores userId + read=false.

ConsoleTest: add testMultiRecipientWithSameMessageIdGeneratesDistinctIds
and update existing tests to use the post-ST2 compound `$id` form
(messageId + 8-hex md5 suffix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:10:38 +12:00
Jake Barnby 3e0df70f5c test(notifications): e2e tests for list, mark-read, tracking pixel, and webhook-failure fanout
Add 6 new test methods to NotificationsBase that exercise the wave3
account-alerts surface end-to-end:

- testListAccountAlertsEmpty: GET /v1/account/alerts shape check
- testWebhookFailureCreatesConsoleAlert: drives a webhook past
  _APP_WEBHOOK_MAX_FAILED_ATTEMPTS via user-create events and polls
  /account/alerts until the worker fans the paused alert out to the
  project owner on the console channel
- testMarkAlertReadTogglesFlag: PATCH /:alertId/read happy path
- testMarkAlertReadUnauthorized: stranger console user cannot mark
  someone else's alert as read; alert remains unread for the owner
- testTrackingPixelTogglesRead: GET /:alertId/track with a valid
  HS256 JWT signed with _APP_OPENSSL_KEY_V1 returns the canonical 1x1
  PNG and atomically marks the alert as read
- testTrackingPixelInvalidTokenReturnsPng: tampered JWT still gets a
  PNG (no information disclosure) but performs no DB write

Helpers:

- seedWebhookFailureAlert: registers a webhook pointing at an
  unroutable address (http://127.0.0.1:1/), drives max+2 user-create
  events through the project, polls assertEventually with a 60s budget
  for the paused alert keyed by the deterministic md5 of
  'webhook:<id>:paused:<attempts>'
- createConsoleUser: spins up a fresh, unrelated console user with its
  own session for the unauthorized assertion
- getConsoleAlertHeaders: console-session auth bundle reused across
  every alerts call, so the trait works identically under SideServer
  and SideConsole hosts
2026-05-06 15:09:28 +12:00
Jake Barnby 080b6dbeb2 merge: ST14 alerts Track body 2026-05-06 14:48:39 +12:00
Jake Barnby 678a6bd079 merge: ST13 alerts XList body 2026-05-06 14:48:36 +12:00
Jake Barnby 3b0c46c85e merge: ST12 PATCH /v1/account/alerts/:alertId/read 2026-05-06 14:48:33 +12:00
Jake Barnby c419257755 merge: ST11 Webhooks worker switch to Notification 2026-05-06 14:48:29 +12:00
Jake BarnbyandClaude Opus 4.7 067bf582dc feat(webhooks): emit pausing alerts via notifications worker to owners on email and console
When a webhook is permanently paused after exceeding the max failure
threshold, fan out the alert through the new Notifications worker
instead of the legacy Mails worker. Recipients are filtered to project
owners only and receive both an email and a console notification, with
a single deduplication key per webhook + attempts so duplicate triggers
collapse downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:46:57 +12:00
Jake BarnbyandClaude Opus 4.7 dae98bf240 feat(notifications): track email opens by decoding pixel JWT and marking alert read
Replace placeholder body with JWT decode, alertId/userId match validation,
sparse update of read=true via injected Authorization::skip. Always returns
1x1 PNG regardless of decode outcome to avoid leaking JWT validity through
response status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:46:33 +12:00
Jake BarnbyandClaude Opus 4.7 5dd987711b feat(notifications): implement GET /v1/account/alerts list endpoint body
Replace the placeholder body of the listAlerts action with real
implementation: scope alerts to the current user via userId equality,
parse incoming queries, support cursor pagination over the alerts
collection, run filters through count for total, and return
MODEL_ALERT_LIST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:46:22 +12:00
Jake BarnbyandClaude Opus 4.7 e54fbf3b3f feat(notifications): mark alert read via PATCH /v1/account/alerts/:alertId/read
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:35:53 +12:00
Jake Barnby 0a9b54543d merge: ST10 queueForNotifications DI registration 2026-05-06 14:20:10 +12:00
Jake Barnby 93bdebb7fa merge: ST9 notifications worker fixes 2026-05-06 14:20:07 +12:00
Jake BarnbyandClaude Opus 4.7 6f01c1492f fix(notifications): worker dedup by attribute, throw on console-zero-delivery, thread recipient userId, inject email tracking pixel
Apply the four Greptile P1 fixes to the Notifications worker and
extend it for C3 email read tracking and ST4's stripped SMTP plumbing.

P1 #1: alreadyDelivered() now queries the indexed messageId attribute
instead of getDocument($messageId). The action loop and Console
adapter both write compound `$id`s (messageId + recipient hash), so
the previous direct-id lookup always missed.

P1 #3: action() no longer calls persistAlert after dispatchConsole;
ConsoleAdapter persists internally. Email persists inside
dispatchEmail BEFORE the adapter send so the alertId is available for
the tracking pixel; webhook persists in the action loop after a
successful HTTP send.

P1 #4: dispatchConsole now throws when the adapter reports
`deliveredTo === 0`, surfacing the per-recipient error.

Recipient threading: dispatch() now takes the full recipient map and
returns the alertId (or null when persistence is the caller's
responsibility). persistAlert() reads userId/teamId from the
recipient and grants per-user / per-team-owner CRUD permissions,
falling back to payload permissions only when neither is set. The
returned alertId lets dispatchEmail splice a 1x1 tracking pixel
before the last `</body>` tag, signed with a 30-day HS256 JWT
(_APP_OPENSSL_KEY_V1) carrying {alertId, userId}.

SMTP resolution: ST4 stripped `smtp` and `customMailOptions` from
the Notification event payload, so the worker now resolves SMTP
from the injected project Document (mirroring Mails.php /
Memberships/Create.php), falling back to the env-driven cloud SMTP
adapter when the project has no enabled override.

Tests updated: SpyNotifications.dispatch() matches the new
signature and emulates per-channel persistence so existing routing
assertions keep their semantics. Memory `alerts` collection adds
the `read` boolean attribute to mirror platform.php.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:16:25 +12:00
Jake BarnbyandClaude Opus 4.7 4e7236a366 feat(notifications): register queueForNotifications resource in worker and request containers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:00:45 +12:00
Jake BarnbyandClaude Opus 4.7 8c7ecd6cdb fix(notifications): add SDK Method group to alerts skeleton actions
PHPStan flagged missing required `group` parameter on the SDK Method
constructors for the alerts XList and Track\Get skeleton actions.
Group set to 'alerts' to align with the resource grouping convention
used elsewhere in the account namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:57:07 +12:00
Jake Barnby 0f1a2b5097 merge: ST4 event recipient + SMTP removal
# Conflicts:
#	src/Appwrite/Event/Notification.php
2026-05-06 13:48:47 +12:00
Jake Barnby a02eb3f41c merge: ST1 NOTIFICATION_TYPE_* rename 2026-05-06 13:48:16 +12:00
Jake Barnby ddffc69914 merge: ST8 alerts track skeleton
# Conflicts:
#	src/Appwrite/Platform/Modules/Account/Services/Http.php
2026-05-06 13:48:10 +12:00
Jake Barnby 439b3db2bf merge: ST7 alerts XList skeleton 2026-05-06 13:47:05 +12:00
Jake Barnby 134a8710da merge: ST6 alerts queries validator 2026-05-06 13:46:59 +12:00
Jake Barnby b9a928d3f8 merge: ST5 alert response model 2026-05-06 13:46:53 +12:00
Jake Barnby d07292ef1f merge: ST3 alerts schema 2026-05-06 13:46:49 +12:00
Jake Barnby 8cafc8c542 merge: ST2 console $id collision fix 2026-05-06 13:46:46 +12:00
Jake BarnbyandClaude Opus 4.7 a0400d2e93 feat(notifications): scaffold GET /v1/account/alerts/:alertId/track endpoint
Adds a public-scope tracking-pixel endpoint skeleton that always returns a
1x1 transparent PNG. The body is intentionally minimal so the email-tracking
read-flag flip can be wired up cleanly in Wave 3 (ST14) once the JWT decode
and DB write paths are designed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:58 +12:00
Jake BarnbyandClaude Opus 4.7 d79be3a6e3 feat(notifications): add alert and alertList response models
Adds Alert response model and MODEL_ALERT/MODEL_ALERT_LIST constants
so future GET /v1/account/alerts endpoint can return MODEL_ALERT_LIST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:27 +12:00
Jake BarnbyandClaude Opus 4.7 55275259d9 feat(notifications): scaffold GET /v1/account/alerts endpoint
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:09 +12:00
Jake BarnbyandClaude Opus 4.7 2c2e920a1a feat(notifications): add alerts queries validator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:42:23 +12:00
Jake BarnbyandClaude Opus 4.7 4351dffb88 refactor(notifications): drop SMTP plumbing from event and extend recipient struct with userId/teamId
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:42:22 +12:00
Jake BarnbyandClaude Opus 4.7 a5587ff096 feat(notifications): track read state and enable per-recipient alert idempotency
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:42:10 +12:00
Jake BarnbyandClaude Opus 4.7 3df219df66 refactor(notifications): consolidate channel constants under NOTIFICATION_TYPE_* prefix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:41:59 +12:00
Jake BarnbyandClaude Opus 4.7 36e1615498 fix(notifications): suffix Console adapter $id with recipient hash to avoid multi-recipient collision
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:41:11 +12:00
Jake BarnbyandClaude Opus 4.7 0ce3978f2c feat(notifications): add worker entrypoint and registration alongside Mails
Adds the bin/worker-notifications entrypoint, Dockerfile chmod, and
docker-compose service definitions for the new Notifications worker
without disturbing the existing Mails worker, and re-adds the Mails
queue/class constants on Event so the legacy callers still resolve.
The full mail->notification swap (caller migration, Mails removal,
worker rename) will land as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:56:43 +12:00
Jake Barnby d6be6e165b test(notifications): split e2e into base + server/console overlays
Mirrors the dominant e2e pattern (FunctionsBase/MigrationsBase): the
shared health-queue assertions live in a NotificationsBase trait,
with thin per-side overlays for ProjectCustom + SideServer and
ProjectCustom + SideConsole.
2026-05-01 15:51:25 +12:00
Jake Barnby 7839b3bb3f refactor(notifications): align webhook signing with per-recipient signatureKey
Drop the global _APP_NOTIFICATIONS_WEBHOOK_SECRET env var. There's no
analogous global webhook secret in Appwrite; the existing Webhooks
worker carries a per-webhook signatureKey on the webhook document.

Move the same pattern into the Notification event: each webhook
recipient may carry an optional signatureKey, which the worker
forwards to the Webhook adapter for HMAC-SHA256 signing. Recipients
without a key are delivered unsigned and a tag is logged for audit.
2026-05-01 15:51:25 +12:00
Jake Barnby 5320c9441b refactor(notifications): rename dedupKey to deduplicationKey
No abbreviations in identifier names.
2026-05-01 15:51:25 +12:00
Jake Barnby 8ad73aa6bb refactor(notifications): use Utopia Fetch client in Webhook adapter
Replace raw curl with Utopia\Fetch\Client to align with the rest of
the codebase (Github OAuth adapter, Install task). The dispatch seam
is preserved so existing tests can still substitute responses.
2026-05-01 15:51:25 +12:00
Jake Barnby 5cbbacaae5 refactor(notifications): move adapters under Appwrite\Utopia\Messaging
Mirror the upstream Utopia\Messaging package namespace under the
Appwrite\Utopia\Messaging prefix, matching the convention used by
Appwrite\Utopia\Database and Appwrite\Utopia\Response.
2026-05-01 15:51:25 +12:00
Jake Barnby 5ee92e1844 fix(notifications): move alerts collection to platform DB
Alerts is a platform-DB-only concern. Move from common.php to
platform.php where it belongs alongside other platform collections.
2026-05-01 15:51:25 +12:00
Jake BarnbyandClaude Opus 4.7 a546c41662 docs(notifications): document _APP_NOTIFICATIONS_WEBHOOK_SECRET
Registers the webhook signing secret in app/config/variables.php under a
new Notifications category, threads it through the worker container in
docker-compose.yml and tests/resources/docker/docker-compose.yml, and
adds an empty default to .env so operators see the knob alongside the
existing SMTP block.

When set, outbound webhook deliveries from the notifications worker
include an X-Appwrite-Webhook-Signature header carrying
sha256=<hex(hmac_sha256(timestamp.body, secret))>. When unset, the
worker delivers payloads unsigned — receivers must decide whether to
accept them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:51:25 +12:00
Jake BarnbyandClaude Opus 4.7 df16c84b51 test(notifications): e2e coverage for notifications queue health
Adds the Notifications e2e suite under tests/e2e/Services/Notifications.
Asserts that the live notifications queue depth is reported via
GET /v1/health/queue/notifications, that the threshold guard is honoured,
and that the failed-jobs endpoint accepts the v1-notifications queue name.

Dispatch routing, dedup, and webhook signing are covered by the unit
suite — the worker cannot be deterministically driven through the live
queue from a test client without an admin enqueue endpoint, so the e2e
file pins the public health contract that ops dashboards and KEDA scale
on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:51:24 +12:00
Jake BarnbyandClaude Opus 4.7 6bde54675e test(notifications): unit tests for worker and adapters
Covers the dedup short-circuit, per-channel dispatch routing, alert
persistence, error tagging, and legacy single-recipient fallback in the
Notifications worker, plus the Console adapter's permission shape and the
Webhook adapter's HMAC-SHA256 signing contract, header layout, response
handling, and unsigned-when-secret-missing behaviour.

Worker dispatch helpers move from private to protected so a test spy can
override them without monkey-patching. The Swoole runtime hook flag
mutation is now guarded by class_exists so the action can run under bare
PHPUnit (no Swoole extension on the test host).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:51:24 +12:00
Jake Barnby 90ef2d7487 feat(notifications): add Notifications worker and register it
Replaces the Mails worker. Resolves recipients into email, console, and
webhook channels, deduplicates deliveries via a hashed dedupKey, and persists
alerts for the console channel.
2026-05-01 12:07:42 +12:00
Jake Barnby 725ba86239 feat(notifications): add notifications schema and channel constants
Adds the alerts collection used by the console channel, plus channel and
provider type constants shared by the worker and adapters.
2026-05-01 12:07:40 +12:00
Jake Barnby 99641f24a5 feat(notifications): add Console and Webhook provider adapters
Console adapter persists alerts directly to the project database. Webhook
adapter dispatches signed JSON payloads (HMAC-SHA256) to subscriber URLs.
2026-05-01 12:07:38 +12:00
Jake Barnby 34935b8eed feat(notifications): add Notification event class
Replaces the Mail event with a multi-channel Notification event capable of
dispatching to email, console, and webhook recipients in a single payload.
Maintains the existing Mail surface (setSubject, setRecipient, setBody, etc.)
so existing call sites can be migrated mechanically.
2026-05-01 12:07:32 +12:00
Matej BačoandGitHub 08ad7d7f71 Merge pull request #12192 from appwrite/fix-oauth-bugs
Fix: OAuth UX with required params
2026-04-30 13:07:52 +02:00
Matej Bačo 71300383b2 Update tests 2026-04-30 12:48:59 +02:00
Matej Bačo 8785aa9877 Fix nullable implementation oauth 2026-04-30 12:41:55 +02:00
Matej Bačo 62b7d5558f Mark params nonrequired 2026-04-30 12:36:45 +02:00
Shmuel FogelandGitHub fbbab0f7e1 Merge pull request #12181 from appwrite/set-global-collection
Set global collection
2026-04-30 12:49:00 +03:00
Matej BačoandGitHub 86b9599a57 Merge pull request #12191 from appwrite/oauth-quality-improvements
Fix: oauth order; Fix apple secreting too much
2026-04-30 11:17:51 +02:00
Luke B. SilverandGitHub 526b390c15 Merge pull request #12189 from FerRubioMorales/fix/spanish-session-alert-translations
fix(locale): add Spanish session alert translations
2026-04-30 10:08:02 +01:00
Matej Bačo b73ba68bfb Fix oauth order; Fix apple secreting too much 2026-04-30 10:21:38 +02:00
fogelito d98bd8c972 Remove line 2026-04-30 10:44:21 +03:00
fogelito 81c580bf50 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into set-global-collection 2026-04-30 10:42:09 +03:00
fogelito c0bba74eee set setGlobalCollections logs 2026-04-30 10:36:12 +03:00
frubio 337d47b1d9 fix(locale): add Spanish session alert translations 2026-04-30 09:33:08 +02:00
Chirag AggarwalandGitHub 8ffe48d948 Merge pull request #12179 from appwrite/fix/project-delete-platform-cleanup 2026-04-30 09:43:37 +05:30
Chirag Aggarwal 4050b9ded1 Continue project cleanup after resource failures 2026-04-30 09:28:22 +05:30
fogelito d099167d18 Shared env 2026-04-29 17:09:25 +03:00
fogelito 9d3255f5cd Update lock 2026-04-29 17:08:14 +03:00
fogelito f03cc847f8 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into set-global-collection
# Conflicts:
#	composer.lock
2026-04-29 17:07:32 +03:00
Torsten DittmannandGitHub 6088fd55c8 Merge pull request #12138 from appwrite/feat-out-of-order-chunk-uploads 2026-04-29 18:04:57 +04:00
Matej BačoandGitHub 1aeee8c407 Merge pull request #12178 from appwrite/fix-developer-experience
Fix: Developer experiene with recent chagnes
2026-04-29 15:56:06 +02:00
fogelito 18b9769672 lock file 2026-04-29 16:14:21 +03:00
fogelito 8eed06678b formatting 2026-04-29 15:54:05 +03:00
fogelito 7a9a2899ff setGlobalCollections 2026-04-29 15:41:56 +03:00
Matej Bačo 36486ccc93 Fix tests 2026-04-29 14:41:19 +02:00
Chirag Aggarwal 794d8eac5b Fix project delete platform cleanup ordering 2026-04-29 17:55:06 +05:30
Matej Bačo 32ebfc6cb8 Fix backwards compatibility 2026-04-29 14:14:49 +02:00
Matej Bačo e1b8f5bf98 review improvements 2026-04-29 14:04:54 +02:00
Matej Bačo 4d86e67006 Fix missing scopes for tables 2026-04-29 14:03:44 +02:00
Matej Bačo b3e3b2a330 Fix missing index scopes 2026-04-29 14:00:14 +02:00
Matej Bačo e010bf25d5 Fix formatting 2026-04-29 13:57:16 +02:00
Matej Bačo aaf91f3816 Improve scopes quality 2026-04-29 13:52:13 +02:00
Matej Bačo bae61e8a05 Improve developer experience of keys endpoints 2026-04-29 13:13:13 +02:00
Torsten DittmannandGitHub dfbf45f4cc Merge branch '1.9.x' into feat-out-of-order-chunk-uploads 2026-04-29 15:03:33 +04:00
Chirag AggarwalandGitHub c264db6146 Merge pull request #12177 from appwrite/codex/fix-1-9-trivy-sarif-categories 2026-04-29 16:13:30 +05:30
Chirag Aggarwal cccafeff0c Add nightly SARIF upload guards 2026-04-29 16:02:41 +05:30
Chirag Aggarwal 360d08f087 Preserve CI image for job retries 2026-04-29 16:01:15 +05:30
Chirag AggarwalandGitHub d8e97ae47d Merge pull request #12174 from appwrite/bump-docker-base-1.2.0 2026-04-29 15:59:24 +05:30
Chirag Aggarwal d13e6d75f0 Fix Trivy SARIF categories on nightly scan 2026-04-29 15:58:59 +05:30
Chirag Aggarwal 4447396859 Update base image to 1.2.1 2026-04-29 15:37:59 +05:30
Chirag AggarwalandGitHub 4d1f229ec7 Merge branch '1.9.x' into bump-docker-base-1.2.0 2026-04-29 15:32:32 +05:30
Chirag AggarwalandGitHub 4f733f5984 Merge pull request #12176 from appwrite/ci-ghcr-image-share 2026-04-29 15:32:22 +05:30
Chirag Aggarwal 701f557755 ci: clean up GHCR CI image after pipeline finishes
Every CI run pushes ghcr.io/<repo>/appwrite-dev:<sha> and nothing
removes it. On an active repo with many PRs the GHCR storage grows
without bound. Add a cleanup job that runs after all consumer jobs
complete (always, even if some fail) and deletes the SHA-tagged
package version via the Packages API.

Addresses Greptile feedback on appwrite/appwrite#12176.
2026-04-29 15:23:05 +05:30
Jake BarnbyandGitHub 8ab26aab44 Merge pull request #12171 from appwrite/migration-refractor
Refactor migrations API to module style
2026-04-29 21:44:19 +12:00
Chirag Aggarwal ec3aa2b54f ci: share docker image via GHCR instead of upload-artifact
The build job uploads the appwrite-dev image as an actions artifact
(~hundreds of MB), and 30+ E2E test jobs all pull it concurrently with
actions/download-artifact. GitHub Actions' artifact storage struggles
with that many parallel downloads and intermittently fails with
BlobNotFound or 'Artifact download failed after 5 retries'.

Push the built image to ghcr.io/<repo>/appwrite-dev:<sha> in the build
job and pull from GHCR in each test job. GHCR handles parallel image
fetches without throttling.

Mirrors appwrite-labs/cloud#3906.
2026-04-29 15:09:39 +05:30
Chirag AggarwalandGitHub 2d636ff7ec Merge branch '1.9.x' into bump-docker-base-1.2.0 2026-04-29 15:06:03 +05:30
Matej BačoandGitHub fd42b8fa64 Merge pull request #12175 from appwrite/feat-console-key-scopes-endpoint
Feat: Console key scopes endpoint
2026-04-29 11:17:49 +02:00
Chirag Aggarwal 9d7df34590 fix: clean up php 8.5 runtime deprecations 2026-04-29 14:47:05 +05:30
Matej Bačo e75fc5b859 Add list scopes endpoint for Console 2026-04-29 10:08:31 +02:00
Jake BarnbyandGitHub 57b8305144 Merge pull request #12134 from appwrite/fix-realtime-span-exporter
added a guard to skip double import
2026-04-29 20:02:04 +12:00
Chirag AggarwalandGitHub a429fb5860 Merge branch '1.9.x' into bump-docker-base-1.2.0 2026-04-29 13:30:50 +05:30
Matej BačoandGitHub aca11ed073 Merge pull request #12170 from appwrite/feat-create-dynamic-keys
Feat: create dynamic keys
2026-04-29 09:58:22 +02:00
Chirag Aggarwal 86123c9e93 fix: update PHP extension path for xdebug cleanup in production 2026-04-29 13:27:04 +05:30
Chirag Aggarwal a58ea1123b chore: bump docker-base to 1.2.0 2026-04-29 13:21:17 +05:30
ArnabChatterjee20kandGitHub dae9cbcf45 Merge pull request #12070 from appwrite/realtime-action-channels
Realtime action channels
2026-04-29 10:49:13 +05:30
Matej Bačo 05f2d2b9cf Fix tests 2026-04-28 19:29:37 +02:00
Matej Bačo c1f61b22aa Merge branch '1.9.x' into feat-create-dynamic-keys 2026-04-28 17:18:36 +02:00
Matej Bačo 980762fc3e Rename from dynamic key to ephemeral key (api keys) 2026-04-28 17:18:06 +02:00
Matej Bačo c96836b1c0 Improve code quality of folder decoding project ID 2026-04-28 17:10:58 +02:00
Matej Bačo 15917ac7ba Fix failing tests 2026-04-28 17:05:30 +02:00
premtsd-codeandGitHub cd851bff24 Merge branch '1.9.x' into migration-refractor 2026-04-28 20:32:54 +05:30
Prem Palanisamy 3f5dcc81fd Refactor migrations API to module style 2026-04-28 15:57:41 +01:00
Matej Bačo f5a732d231 Add dynami key integration test 2026-04-28 16:47:39 +02:00
Matej Bačo 72dfd8a7bc Add E2E tests for dynamic keys 2026-04-28 16:45:00 +02:00
Matej Bačo 11f80fc2ed Solve key projectId backwards compatibility 2026-04-28 16:35:40 +02:00
Harsh MahajanandGitHub 547709a1d8 Merge pull request #12167 from appwrite/feat/impersonation-query-params
feat: add query param fallback for impersonation headers
2026-04-28 19:51:23 +05:30
Matej Bačo ccb0ddd578 Bug&test fixing 2026-04-28 16:18:36 +02:00
Matej Bačo b2ce95a0cd Dynamic key backwards compatibility 2026-04-28 16:14:10 +02:00
Matej Bačo ed9b47f6ce Migrate project jwt to dynamic api key 2026-04-28 15:57:37 +02:00
harsh mahajan 2a357511ea fix: use unique emails and phone in query param impersonation test 2026-04-28 19:17:25 +05:30
Harsh MahajanandGitHub 67d24d3ef1 Merge branch '1.9.x' into feat/impersonation-query-params 2026-04-28 19:11:14 +05:30
harsh mahajan 87ed7c3817 feat: add query param fallback for all impersonation params and simplify tests 2026-04-28 19:10:55 +05:30
Matej Bačo 8f176166c9 Re-introduce project JWT endpoint 2026-04-28 15:31:10 +02:00
Torsten Dittmann a0ef145b92 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into feat-out-of-order-chunk-uploads 2026-04-28 17:10:56 +04:00
Matej BačoandGitHub 3d3f5934c6 Merge pull request #11993 from appwrite/feat-public-oauth2-endpoints
Feat: Public project OAuth2 configuration API
2026-04-28 12:41:50 +02:00
Torsten Dittmann 9e1f8af103 fix: persist sourceChunksTotal/Uploaded in finalization createDocument paths
Greptile review: Functions and Sites finalization branches reached via
single-chunk uploads or out-of-order last-chunk assembly omitted
sourceChunksTotal and sourceChunksUploaded in createDocument. This caused
the retry guard to evaluate 0 === 1 on retry, missing and queuing duplicate
builds.
2026-04-28 13:44:41 +04:00
Torsten DittmannandGitHub b055ff1066 Merge branch '1.9.x' into feat-out-of-order-chunk-uploads 2026-04-28 13:19:08 +04:00
harsh mahajan f0cbfbbbe4 fix: use assertEmpty for impersonatorUserId to match response model 2026-04-28 14:31:49 +05:30
Matej Bačo cb4cff120b Add Keycloak oauth support 2026-04-28 10:54:13 +02:00
Matej Bačo 49e6a38e7f Add fusionauth oauth 2026-04-28 10:43:16 +02:00
Matej Bačo dfa3ae5274 Fix tests 2026-04-28 10:19:36 +02:00
Matej Bačo 543765a22a Improve copy 2026-04-28 10:15:45 +02:00
Matej Bačo e2bb9a9161 Simplify oauth endpoints 2026-04-28 10:08:39 +02:00
harsh mahajan bda823ac0e chore: format 2026-04-28 13:38:00 +05:30
harsh mahajan 3dd5a51ba4 style: fix method argument spacing (Pint PSR-12) 2026-04-28 13:34:01 +05:30
harsh mahajan 5afc8f462d fix: allow same-site in CSRF guard to support Console on subdomains 2026-04-28 13:26:13 +05:30
harsh mahajan ed0c7b4e12 test: add CSRF attack prevention test for impersonateUserId query param 2026-04-28 13:24:15 +05:30
Matej Bačo d25707346f Add console oauth endpoint 2026-04-28 09:47:27 +02:00
harsh mahajan a3f6cf4645 fix: restrict CSRF guard to same-origin only, drop same-site 2026-04-28 13:00:18 +05:30
harsh mahajan 9a175c5098 test: add E2E tests for impersonateUserId query param and CSRF guards 2026-04-28 12:56:17 +05:30
harsh mahajan 5465be6301 fix: make CSRF guard fail-closed by requiring explicit same-origin Sec-Fetch-Site 2026-04-28 12:27:57 +05:30
harsh mahajan 46a457bfa3 fix: block impersonateUserId query param on cross-site requests to prevent CSRF 2026-04-28 12:10:51 +05:30
harsh mahajan 4c989f99c3 fix: cast impersonateUserId query param to string to prevent array injection 2026-04-28 12:05:02 +05:30
harsh mahajan 8f1d73a6cb chore: clarify intentional header-only restriction for email/phone impersonation 2026-04-28 12:02:00 +05:30
harsh mahajan 01b5fa8ecb fix: restrict impersonation query param fallback to userId only
Remove query param fallback for impersonateEmail and impersonatePhone
to avoid PII exposure in server logs, browser history, and Referer
headers. Only impersonateUserId (an opaque internal ID) is safe to
pass via URL query param.
2026-04-28 11:58:25 +05:30
harsh mahajan d73b7a70d8 feat: add query param fallback for impersonation headers
Allow impersonation to be specified via URL query params
(?impersonateUserId, ?impersonateEmail, ?impersonatePhone) as a
fallback to the existing headers, enabling Console to embed
impersonation in direct file/image URLs where headers cannot be set.
2026-04-28 11:44:39 +05:30
ArnabChatterjee20k f71a2dfddc changed the condition to app edition for the loading of the span 2026-04-28 11:07:16 +05:30
Damodar LohaniandGitHub cefd063c55 Merge pull request #12165 from appwrite/fix/CLO-4280-getheader-string-coerce
fix: coerce non-string header values in Request::getHeader
2026-04-28 10:43:40 +05:45
Damodar LohaniandGitHub c924cbcc59 Merge pull request #12166 from appwrite/fix/CLO-4279-favicon-empty-body
fix: guard DOMDocument::loadHTML against empty body in favicon endpoint
2026-04-28 10:32:32 +05:45
Damodar LohaniGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
81321e82d1 Update src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-28 10:05:01 +05:45
Damodar Lohani 30a511692b test: add unit coverage for Request::getHeader non-string coercion
Refs CLO-4280
2026-04-28 04:15:00 +00:00
Damodar Lohani 9637409831 fix: coerce non-string header values in Request::getHeader
Closes CLO-4280
2026-04-28 03:54:35 +00:00
Damodar Lohani c4f6b11706 fix: guard DOMDocument::loadHTML against empty body in favicon endpoint
Closes CLO-4279
2026-04-28 03:54:34 +00:00
Matej Bačo ad4178aa42 Fix missing lib params for domain 2026-04-27 18:33:30 +02:00
Matej Bačo 1f16b0d9e7 Fix failing startup 2026-04-27 18:21:21 +02:00
Matej Bačo 015aee087a Fix write only security 2026-04-27 18:04:22 +02:00
Matej Bačo 50d86c5b5d Update ci.yml 2026-04-27 17:45:52 +02:00
Matej Bačo 3d43530225 Fix failing test 2026-04-27 17:41:13 +02:00
Matej Bačo d0d536a2dd Improve test coverage 2026-04-27 17:40:49 +02:00
Matej Bačo 4b620bb31a Improve test coverage 2026-04-27 17:27:23 +02:00
Matej Bačo ca7f36a9b8 Fix bugs by improving tests 2026-04-27 17:17:57 +02:00
Matej Bačo ec3c7f1ad6 Fix failing oauth tests 2026-04-27 17:02:53 +02:00
Matej Bačo ecba11eba5 Brin back removed tests 2026-04-27 16:54:53 +02:00
Matej Bačo 7a96b024b3 Fix tests 2026-04-27 16:51:01 +02:00
Matej Bačo 4ba413fcc0 Fix bugs when implementing tests 2026-04-27 16:50:14 +02:00
Matej Bačo af95e71244 Add OAUth update tests 2026-04-27 16:02:19 +02:00
Matej Bačo ee1eea5c0c oauth tests setup 2026-04-27 15:51:54 +02:00
Matej Bačo b28b851bb2 microsoft oauth endpoint 2026-04-27 15:49:44 +02:00
Torsten Dittmann 2f2da98cca fix: adjust out-of-order test expectations and chunk sizes
- Functions/Sites: lower minimum chunk requirement from 3 to 2
- Sites: use random_bytes instead of str_repeat for non-compressible test data
- Remove assertions on sourceChunksTotal/Uploaded from response body (not in response model)
2026-04-27 17:46:20 +04:00
Torsten Dittmann 54997638e8 fix: persist sourceChunksUploaded on finalization and avoid variable shadowing
- Functions/Sites: include sourceChunksUploaded in updateDocument when finalizing existing deployments, fixing the retry guard
- Functions test: rename loop variable to avoid shadowing setup result
2026-04-27 17:24:51 +04:00
Torsten Dittmann 49d2db65e6 feat: support out-of-order chunked uploads
- Add APP_LIMIT_UPLOAD_CHUNK_SIZE constant (5MB) matching official SDKs
- Replace dynamic chunk calculation with fixed 5MB chunk math in all upload endpoints
- Remove -1 last-chunk sentinel that broke when last chunk arrived first
- Fix duplicate-retry guards: return existing resource instead of erroring for chunked uploads
- Add out-of-order e2e tests for Storage, Functions, and Sites
- Upgrade utopia-php/storage to 2.0.0 for device-level out-of-order assembly support
2026-04-27 17:15:00 +04:00
ArnabChatterjee20k 1fdcca9592 added a guard to skip double import 2026-04-27 18:33:47 +05:30
Matej Bačo a781325679 Add oauth read operations 2026-04-27 14:47:47 +02:00
Matej Bačo 2e57500d7e WIP: Read endpoints for oauth 2026-04-27 14:16:43 +02:00
Matej Bačo a1a88ae57e Make oauth secret write only 2026-04-27 14:09:24 +02:00
Matej Bačo 15f94d99ca Add Kick OAuth adapter 2026-04-27 14:02:30 +02:00
Matej Bačo 2e960b90df Fix unused env variable 2026-04-27 13:38:26 +02:00
Matej Bačo 8ce7aa2abe Fix crashing http 2026-04-27 12:27:52 +02:00
Matej Bačo e4bfb38a57 add okta provider 2026-04-26 11:14:50 +02:00
Matej Bačo 0a7b7de197 Revert changes - default works as fallback for optional serverID 2026-04-26 10:59:29 +02:00
Matej Bačo 51c0767be2 Make okta server ID optional 2026-04-26 10:56:41 +02:00
Matej Bačo d25dac7d60 Manual quality improvmenets 2026-04-26 10:29:41 +02:00
Matej BačoandGitHub 1f18e16310 Merge branch '1.9.x' into feat-public-oauth2-endpoints 2026-04-25 12:45:34 +02:00
Matej Bačo d0f6daa67a Fix integration test 2026-04-25 12:05:35 +02:00
Matej Bačo 184399023c Add github integration test 2026-04-25 11:58:09 +02:00
Matej Bačo a588a62277 Prepare env for cicd integration with github oauth 2026-04-25 11:57:40 +02:00
Matej Bačo ffd0dbd406 Add OIDC endpoint 2026-04-25 10:20:00 +02:00
Matej Bačo 8200d079c6 Simplify specs 2026-04-24 16:37:27 +02:00
Matej Bačo d9d87f813f apple oauth endpoints 2026-04-24 16:31:21 +02:00
Matej Bačo db7acd4b8b More OAuth endpoints 2026-04-24 15:02:36 +02:00
Matej Bačo a62ca8612d More OAuth endpoints 2026-04-24 14:31:38 +02:00
Matej Bačo 975da667f5 Remove leftover 2026-04-24 14:23:19 +02:00
Matej Bačo 6cfb12c48b Improve OAuth SDK quality 2026-04-24 14:23:04 +02:00
Matej Bačo 8cdcd379c8 Add more oauth endpoints 2026-04-24 14:15:34 +02:00
Matej Bačo fe08978851 More OAuth provider endpoints 2026-04-24 12:58:32 +02:00
Matej Bačo faf09ed7c5 Abstrated oauth response model 2026-04-24 12:38:12 +02:00
Matej Bačo c097d9fcdd Dropbox adapter 2026-04-24 12:20:48 +02:00
Matej Bačo dac184b281 abstract oauth adapters 2026-04-24 12:06:58 +02:00
Matej Bačo 335b1c2f6c Figma OAuth endpoint 2026-04-24 11:45:59 +02:00
Matej Bačo 5fbe6cba79 Improve github samples 2026-04-24 11:39:14 +02:00
Matej Bačo 36435d940d Add Discord OAuth endpoint 2026-04-24 11:35:30 +02:00
Matej Bačo 93f7a0d902 GitHub oauth endpoint 2026-04-24 11:17:18 +02:00
Matej Bačo 7fbfb6266b GitHub oauth response model 2026-04-24 10:56:39 +02:00
274 changed files with 18831 additions and 1976 deletions
@@ -0,0 +1,174 @@
# Parallel Chunk Upload Support for utopia-php/storage
## Context
The Appwrite API now supports out-of-order chunked uploads (chunks can arrive in any sequence). The next step is **parallel uploads** — multiple chunks uploaded simultaneously via separate HTTP requests. The SDK guarantees the first chunk is sent before any parallel chunks, so the document creation race is handled at the API layer. However, the storage device layer has a race condition that must be fixed.
## Problem: `Local::joinChunks()` Race
When two requests upload the final missing chunks in parallel, both can observe `countChunks() == $chunks` and call `joinChunks()` simultaneously.
### Current behavior (loser throws)
```php
// Local::joinChunks()
$dest = \fopen($tmpAssemble, 'wb');
// ... stream all parts into $tmpAssemble ...
if (! \rename($tmpAssemble, $path)) {
\unlink($tmpAssemble);
throw new Exception('Failed to finalize assembled file '.$path);
}
```
The winner succeeds with `rename()`. The loser gets `false` from `rename()` (file already exists at `$path`) and throws a 500-error exception. The client that lost the race receives an error even though the file is fully assembled.
### Required behavior
If `$path` already exists, another request already assembled the file. The loser should **silently succeed** — the file is complete, nothing more to do.
## Proposed Changes
### 1. `Local::joinChunks()` — Handle assembly race
Before opening `$tmpAssemble`, check if the final file already exists. If it does, skip assembly entirely.
```php
private function joinChunks(string $path, int $chunks): void
{
// Race winner already assembled the file
if (\file_exists($path)) {
return;
}
$tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.asename($path);
$tmpAssemble = \dirname($path).DIRECTORY_SEPARATOR.'tmp_assemble_'.asename($path);
// ... rest of assembly logic ...
if (! \rename($tmpAssemble, $path)) {
// Another request may have won the race between fclose and rename
if (\file_exists($path)) {
\unlink($tmpAssemble);
return;
}
\unlink($tmpAssemble);
throw new Exception('Failed to finalize assembled file '.$path);
}
// ... cleanup ...
}
```
### 2. `Local::countChunks()` — Reliability under concurrent writes
`countChunks()` uses `glob()` on the temp directory. Under heavy parallel load, `glob()` might miss files or return inconsistent counts. The current implementation is already fairly robust (it validates `.part.\d+` suffix), but we should document that the return value is a best-effort snapshot.
No code change needed here unless tests reveal issues.
### 3. Tests — Concurrent chunk uploads
Add a test that simulates two parallel requests completing a multi-chunk upload:
```php
public function testParallelChunkUpload(): void
{
$storage = $this->makeJoinTestStorage();
$dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel.dat';
// Upload chunk 1 (creates temp directory)
$storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2);
// Simulate two parallel requests uploading the last chunk
// In a real test, use pcntl_fork() or pthreads for true concurrency
// For the test suite, sequential calls are sufficient if we verify
// the second call doesn't throw after the first completed assembly
$storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
// Verify file exists and is correct
$this->assertTrue(\file_exists($dest));
$this->assertSame('AAAABBBB', \file_get_contents($dest));
// Verify second assembly attempt doesn't throw
// (This simulates the race where another request already assembled)
try {
$storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
} catch (\Exception $e) {
$this->fail('Duplicate assembly should not throw: '.$e->getMessage());
}
$storage->delete($storage->getRoot(), true);
}
```
A more realistic concurrent test using `pcntl_fork()`:
```php
public function testParallelChunkUploadWithFork(): void
{
if (!\function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl extension required for fork-based concurrency test');
}
$storage = $this->makeJoinTestStorage();
$dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel-fork.dat';
// Pre-upload chunk 1
$storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2);
$pid = pcntl_fork();
if ($pid === -1) {
$this->fail('Failed to fork');
} elseif ($pid === 0) {
// Child process: upload chunk 2
try {
$storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
exit(0);
} catch (\Exception $e) {
exit(1);
}
}
// Parent process: also upload chunk 2 (race condition)
$parentSuccess = true;
try {
$storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
} catch (\Exception $e) {
$parentSuccess = false;
}
pcntl_waitpid($pid, $status);
$childSuccess = pcntl_wexitstatus($status) === 0;
// At least one should succeed
$this->assertTrue($parentSuccess || $childSuccess, 'At least one parallel upload should succeed');
// File should be correctly assembled
$this->assertTrue(\file_exists($dest));
$this->assertSame('AAAABBBB', \file_get_contents($dest));
$storage->delete($storage->getRoot(), true);
}
```
## S3 Device
S3 already handles out-of-order multipart uploads natively. The `completeMultipartUpload` call with `ksort()` sorts parts by number regardless of upload order. However, parallel `completeMultipartUpload` calls for the same `uploadId` would still be problematic.
This is an **API-layer concern** — the Appwrite API should ensure only one request calls `completeMultipartUpload` per upload. The S3 device itself does not need changes.
## Files to Change
| File | Change |
|------|--------|
| `src/Storage/Device/Local.php` | Add `file_exists($path)` guard at start of `joinChunks()` and in `rename()` failure handler |
| `tests/Storage/Device/LocalTest.php` | Add `testParallelChunkUpload` and `testParallelChunkUploadWithFork` |
## Backwards Compatibility
Fully backwards compatible. The change only affects the error path when `rename()` fails due to an existing file. Previously it threw; now it returns silently. No public API signatures change.
## Related PRs
- Appwrite server PR: https://github.com/appwrite/appwrite/pull/12138 (out-of-order upload support)
- This storage PR is a prerequisite for the follow-up Appwrite PR that enables parallel chunk uploads at the API level.
+4
View File
@@ -47,6 +47,8 @@ _APP_DB_SCHEMA=appwrite
_APP_DB_USER=user
_APP_DB_PASS=password
_APP_DB_ROOT_PASS=rootsecretpassword
_APP_DATABASE_SHARED_TABLES=
_APP_DATABASE_SHARED_NAMESPACE=
_APP_DB_ADAPTER_DOCUMENTSDB=mongodb
_APP_DB_HOST_DOCUMENTSDB=mongodb
_APP_DB_PORT_DOCUMENTSDB=27017
@@ -146,3 +148,5 @@ _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_TRUSTED_HEADERS=x-forwarded-for
_APP_POOL_ADAPTER=stack
_APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite
_TESTS_OAUTH2_GITHUB_CLIENT_ID=
_TESTS_OAUTH2_GITHUB_CLIENT_SECRET=
+97 -57
View File
@@ -7,6 +7,7 @@ concurrency:
env:
COMPOSE_FILE: docker-compose.yml
IMAGE: appwrite-dev
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}/appwrite-dev
K6_VERSION: '0.53.0'
on:
@@ -19,6 +20,10 @@ on:
type: string
default: ''
permissions:
contents: read
packages: write
jobs:
dependencies:
name: Checks / Dependencies
@@ -258,32 +263,30 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Appwrite
- name: Build and push Appwrite
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: ${{ env.IMAGE }}
load: true
push: true
tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
target: development
build-args: |
DEBUG=false
TESTING=true
VERSION=dev
- name: Upload Docker Image
uses: actions/upload-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp/${{ env.IMAGE }}.tar
retention-days: 1
unit:
name: Tests / Unit
runs-on: ubuntu-latest
@@ -291,26 +294,32 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
steps:
- name: checkout
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -338,26 +347,32 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
steps:
- name: checkout
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -396,6 +411,7 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
strategy:
fail-fast: false
matrix:
@@ -450,14 +466,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Set database environment
- name: Set environment
run: |
echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV
if [ "${{ matrix.database }}" = "MariaDB" ]; then
echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV
echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
@@ -481,6 +493,18 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
@@ -489,7 +513,6 @@ jobs:
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -526,6 +549,8 @@ jobs:
docker compose exec -T \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
-e _TESTS_OAUTH2_GITHUB_CLIENT_ID="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_ID }}" \
-e _TESTS_OAUTH2_GITHUB_CLIENT_SECRET="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_SECRET }}" \
appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
- name: Failure Logs
@@ -541,6 +566,7 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
strategy:
fail-fast: false
matrix:
@@ -551,18 +577,24 @@ jobs:
with:
fetch-depth: 1
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
@@ -571,7 +603,6 @@ jobs:
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -602,6 +633,7 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
strategy:
fail-fast: false
matrix:
@@ -610,18 +642,24 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
@@ -629,7 +667,6 @@ jobs:
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -671,28 +708,31 @@ jobs:
contents: read
issues: write
pull-requests: write
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load Appwrite image
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Appwrite image
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after
- name: Setup k6
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
+31 -1
View File
@@ -5,6 +5,11 @@ on:
types:
- closed
permissions:
actions: write
contents: read
packages: write
jobs:
cleanup:
runs-on: ubuntu-latest
@@ -36,4 +41,29 @@ jobs:
done
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cleanup GHCR image
continue-on-error: true
run: |
package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev"
encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)"
gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits" --jq '.[].sha' | while read -r sha; do
version_ids=$(gh api --paginate -H "Accept: application/vnd.github+json" \
"/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \
--jq ".[] | select(.metadata.container.tags | index(\"${sha}\")) | .id")
if [ -z "$version_ids" ]; then
echo "No GHCR version found for SHA ${sha}"
continue
fi
echo "$version_ids" | while read -r version_id; do
gh api --method DELETE -H "Accept: application/vnd.github+json" \
"/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}"
echo "Deleted ${package_path}:${sha} (version ${version_id})"
done
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -2
View File
@@ -24,9 +24,11 @@ jobs:
ignore-unfixed: 'false'
severity: 'CRITICAL,HIGH'
- name: Upload Docker Image Scan Results
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
category: 'trivy-image'
scan-code:
name: Scan Code
@@ -42,6 +44,8 @@ jobs:
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Code Scan Results
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
category: 'trivy-source'
+8 -2
View File
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
FROM appwrite/base:1.0.1 AS base
FROM appwrite/base:1.2.1 AS base
LABEL maintainer="team@appwrite.io"
@@ -24,6 +24,10 @@ ENV _APP_VERSION=$VERSION \
_APP_HOME=https://appwrite.io
RUN \
if [ "$DEBUG" != "true" ]; then \
rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \
fi && \
if [ "$DEBUG" == "true" ]; then \
apk add boost boost-dev; \
fi
@@ -89,6 +93,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/worker-functions && \
chmod +x /usr/local/bin/worker-mails && \
chmod +x /usr/local/bin/worker-messaging && \
chmod +x /usr/local/bin/worker-notifications && \
chmod +x /usr/local/bin/worker-migrations && \
chmod +x /usr/local/bin/worker-webhooks && \
chmod +x /usr/local/bin/worker-stats-usage && \
@@ -100,7 +105,8 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/
FROM base AS production
RUN rm -rf /usr/src/code/app/config/specs && \
rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \
rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \
rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so && \
find /usr -name '*.a' -delete 2>/dev/null || true && \
find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \
find /usr -name '*.pyc' -delete 2>/dev/null || true
+20
View File
@@ -157,12 +157,19 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
}
if (isset($databases[$dsn->getHost()])) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database = $databases[$dsn->getHost()];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -182,9 +189,16 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -212,6 +226,11 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
return $database;
}
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -220,6 +239,7 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
->setAuthorization($authorization)
->setSharedTables(true)
->setNamespace('logsV1')
->setGlobalCollections($logsCollections)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+144
View File
@@ -1011,6 +1011,150 @@ $platformCollections = [
],
],
'alerts' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('alerts'),
'name' => 'Alerts',
'attributes' => [
[
'$id' => ID::custom('messageId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('type'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => false,
'default' => 'info',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('channel'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('userId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('teamId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('projectId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('title'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('body'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 16384,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('read'),
'type' => Database::VAR_BOOLEAN,
'format' => '',
'size' => 0,
'signed' => true,
'required' => false,
'default' => false,
'array' => false,
'filters' => [],
],
],
'indexes' => [
[
'$id' => ID::custom('_key_messageId'),
'type' => Database::INDEX_KEY,
'attributes' => ['messageId'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_recipient'),
'type' => Database::INDEX_UNIQUE,
'attributes' => ['messageId', 'channel', 'userId', 'teamId'],
'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY, Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_userId_read'),
'type' => Database::INDEX_KEY,
'attributes' => ['userId', 'read'],
'lengths' => [Database::LENGTH_KEY, 0],
'orders' => [Database::ORDER_ASC, Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_teamId_read'),
'type' => Database::INDEX_KEY,
'attributes' => ['teamId', 'read'],
'lengths' => [Database::LENGTH_KEY, 0],
'orders' => [Database::ORDER_ASC, Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_projectId'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectId'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
],
],
],
'certificates' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('certificates'),
+1 -1
View File
@@ -384,7 +384,7 @@ return [
],
Exception::API_KEY_EXPIRED => [
'name' => Exception::API_KEY_EXPIRED,
'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.',
'description' => 'The ephemeral API key has expired. Please don\'t use ephemeral API keys for more than duration of the execution.',
'code' => 401,
],
+10
View File
@@ -28,6 +28,16 @@
"emails.invitation.thanks": "Gracias.,",
"emails.invitation.buttonText": "Aceptar invitación a {{team}}",
"emails.invitation.signature": "El equipo de {{project}}",
"emails.sessionAlert.subject": "Alerta de seguridad: nueva sesión en tu cuenta de {{project}}",
"emails.sessionAlert.preview": "Nuevo inicio de sesión detectado en {{project}} a las {{time}} UTC.",
"emails.sessionAlert.hello": "Hola {{user}},",
"emails.sessionAlert.body": "Se ha creado una nueva sesión en tu cuenta de {{b}}{{project}}{{/b}}, {{b}}el {{date}} de {{year}} a las {{time}} UTC{{/b}}.\nEstos son los detalles de la nueva sesión:",
"emails.sessionAlert.listDevice": "Dispositivo: {{b}}{{device}}{{/b}}",
"emails.sessionAlert.listIpAddress": "Dirección IP: {{b}}{{ipAddress}}{{/b}}",
"emails.sessionAlert.listCountry": "País: {{b}}{{country}}{{/b}}",
"emails.sessionAlert.footer": "Si has sido tú, no tienes que hacer nada más.\nSi no has iniciado esta sesión o sospechas actividad no autorizada, protege tu cuenta.",
"emails.sessionAlert.thanks": "Gracias,",
"emails.sessionAlert.signature": "El equipo de {{project}}",
"locale.country.unknown": "Desconocido",
"countries.af": "Afganistán",
"countries.ao": "Angola",
+33
View File
@@ -167,6 +167,17 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Figma',
],
'fusionauth' => [
'name' => 'FusionAuth',
'developers' => 'https://fusionauth.io/docs/',
'icon' => 'icon-fusionauth',
'enabled' => true,
'sandbox' => false,
'form' => 'fusionauth.phtml',
'beta' => false,
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\FusionAuth',
],
'github' => [
'name' => 'GitHub',
'developers' => 'https://developer.github.com/',
@@ -200,6 +211,28 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Google',
],
'keycloak' => [
'name' => 'Keycloak',
'developers' => 'https://www.keycloak.org/documentation',
'icon' => 'icon-keycloak',
'enabled' => true,
'sandbox' => false,
'form' => 'keycloak.phtml',
'beta' => false,
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Keycloak',
],
'kick' => [
'name' => 'Kick',
'developers' => 'https://docs.kick.com/',
'icon' => 'icon-kick',
'enabled' => true,
'sandbox' => false,
'form' => false,
'beta' => false,
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Kick',
],
'linkedin' => [
'name' => 'LinkedIn',
'developers' => 'https://developer.linkedin.com/',
+7 -5
View File
@@ -21,8 +21,8 @@ $member = [
'projects.read',
'locale.read',
'avatars.read',
'execution.read',
'execution.write',
'executions.read',
'executions.write',
'targets.read',
'targets.write',
'subscribers.write',
@@ -55,6 +55,8 @@ $admins = [
'tables.write',
'platforms.read',
'platforms.write',
'oauth2.read',
'oauth2.write',
'mocks.read',
'mocks.write',
'policies.read',
@@ -79,8 +81,8 @@ $admins = [
'sites.write',
'log.read',
'log.write',
'execution.read',
'execution.write',
'executions.read',
'executions.write',
'rules.read',
'rules.write',
'migrations.read',
@@ -121,7 +123,7 @@ return [
'files.write',
'locale.read',
'avatars.read',
'execution.write',
'executions.write',
],
],
User::ROLE_USERS => [
+289 -180
View File
@@ -1,231 +1,340 @@
<?php
return [ // List of publicly visible scopes
'sessions.write' => [
'description' => 'Access to create, update, and delete user sessions',
],
'users.read' => [
'description' => 'Access to read your project\'s users',
],
'users.write' => [
'description' => 'Access to create, update, and delete your project\'s users',
],
'teams.read' => [
'description' => 'Access to read your project\'s teams',
],
'teams.write' => [
'description' => 'Access to create, update, and delete your project\'s teams',
],
'databases.read' => [
'description' => 'Access to read your project\'s databases',
],
'databases.write' => [
'description' => 'Access to create, update, and delete your project\'s databases',
],
'collections.read' => [
'description' => 'Access to read your project\'s database collections',
],
'collections.write' => [
'description' => 'Access to create, update, and delete your project\'s database collections',
],
'tables.read' => [
'description' => 'Access to read your project\'s database tables',
],
'tables.write' => [
'description' => 'Access to create, update, and delete your project\'s database tables',
],
'attributes.read' => [
'description' => 'Access to read your project\'s database collection\'s attributes',
],
'attributes.write' => [
'description' => 'Access to create, update, and delete your project\'s database collection\'s attributes',
],
'columns.read' => [
'description' => 'Access to read your project\'s database table\'s columns',
],
'columns.write' => [
'description' => 'Access to create, update, and delete your project\'s database table\'s columns',
],
'indexes.read' => [
'description' => 'Access to read your project\'s database table\'s indexes',
],
'indexes.write' => [
'description' => 'Access to create, update, and delete your project\'s database table\'s indexes',
],
'documents.read' => [
'description' => 'Access to read your project\'s database documents',
],
'documents.write' => [
'description' => 'Access to create, update, and delete your project\'s database documents',
],
'rows.read' => [
'description' => 'Access to read your project\'s database rows',
],
'rows.write' => [
'description' => 'Access to create, update, and delete your project\'s database rows',
],
'files.read' => [
'description' => 'Access to read your project\'s storage files and preview images',
],
'files.write' => [
'description' => 'Access to create, update, and delete your project\'s storage files',
],
'buckets.read' => [
'description' => 'Access to read your project\'s storage buckets',
],
'buckets.write' => [
'description' => 'Access to create, update, and delete your project\'s storage buckets',
],
'functions.read' => [
'description' => 'Access to read your project\'s functions and code deployments',
],
'functions.write' => [
'description' => 'Access to create, update, and delete your project\'s functions and code deployments',
],
'sites.read' => [
'description' => 'Access to read your project\'s sites and deployments',
],
'sites.write' => [
'description' => 'Access to create, update, and delete your project\'s sites and deployments',
],
'log.read' => [
'description' => 'Access to read your site\'s logs',
],
'log.write' => [
'description' => 'Access to update, and delete your site\'s logs',
],
'execution.read' => [
'description' => 'Access to read your project\'s execution logs',
],
'execution.write' => [
'description' => 'Access to execute your project\'s functions',
],
'locale.read' => [
'description' => 'Access to access your project\'s Locale service',
],
'avatars.read' => [
'description' => 'Access to access your project\'s Avatars service',
],
'health.read' => [
'description' => 'Access to read your project\'s health status',
],
'providers.read' => [
'description' => 'Access to read your project\'s providers',
],
'providers.write' => [
'description' => 'Access to create, update, and delete your project\'s providers',
],
'messages.read' => [
'description' => 'Access to read your project\'s messages',
],
'messages.write' => [
'description' => 'Access to create, update, and delete your project\'s messages',
],
'topics.read' => [
'description' => 'Access to read your project\'s topics',
],
'topics.write' => [
'description' => 'Access to create, update, and delete your project\'s topics',
],
'subscribers.read' => [
'description' => 'Access to read your project\'s subscribers',
],
'subscribers.write' => [
'description' => 'Access to create, update, and delete your project\'s subscribers',
],
'targets.read' => [
'description' => 'Access to read your project\'s targets',
],
'targets.write' => [
'description' => 'Access to create, update, and delete your project\'s targets',
],
'rules.read' => [
'description' => 'Access to read your project\'s proxy rules',
],
'rules.write' => [
'description' => 'Access to create, update, and delete your project\'s proxy rules',
],
'schedules.read' => [
'description' => 'Access to read your project\'s schedules',
],
'schedules.write' => [
'description' => 'Access to create, update, and delete your project\'s schedules',
],
'migrations.read' => [
'description' => 'Access to read your project\'s migrations',
],
'migrations.write' => [
'description' => 'Access to create, update, and delete your project\'s migrations.',
],
'vcs.read' => [
'description' => 'Access to read your project\'s VCS repositories',
],
'vcs.write' => [
'description' => 'Access to create, update, and delete your project\'s VCS repositories',
],
'assistant.read' => [
'description' => 'Access to read the Assistant service',
],
'tokens.read' => [
'description' => 'Access to read your project\'s tokens',
],
'tokens.write' => [
'description' => 'Access to create, update, and delete your project\'s tokens',
],
"webhooks.read" => [
"description" =>
"Access to read project\'s webhooks",
],
"webhooks.write" => [
"description" =>
"Access to create, update, and delete project\'s webhooks",
],
// List of publicly visible scopes
return [
// Project
"project.read" => [
"description" =>
"Access to read project\'s information",
"category" => "Project",
],
"project.write" => [
"description" =>
"Access to update project\'s information",
"category" => "Project",
],
"keys.read" => [
"description" =>
"Access to read project\'s keys",
"category" => "Project",
],
"keys.write" => [
"description" =>
"Access to create, update, and delete project\'s keys",
"category" => "Project",
],
"platforms.read" => [
"description" =>
"Access to read project\'s platforms",
"category" => "Project",
],
"platforms.write" => [
"description" =>
"Access to create, update, and delete project\'s platforms",
"category" => "Project",
],
"mocks.read" => [
"description" =>
"Access to read project\'s mocks",
"category" => "Project",
],
"mocks.write" => [
"description" =>
"Access to create, update, and delete project\'s mocks",
"category" => "Project",
],
"policies.read" => [
"description" =>
"Access to read project\'s policies",
"category" => "Project",
],
"policies.write" => [
"description" =>
"Access to update project\'s policies",
"category" => "Project",
],
"templates.read" => [
"description" =>
"Access to read project\'s templates",
"category" => "Project",
],
"templates.write" => [
"description" =>
"Access to create, update, and delete project\'s templates",
"category" => "Project",
],
"oauth2.read" => [
"description" =>
"Access to read project\'s OAuth2 configuration",
"category" => "Project",
],
"oauth2.write" => [
"description" =>
"Access to update project\'s OAuth2 configuration",
"category" => "Project",
],
// Auth
'users.read' => [
'description' => 'Access to read users',
'category' => 'Auth',
],
'users.write' => [
'description' => 'Access to create, update, and delete users',
'category' => 'Auth',
],
'sessions.read' => [
'description' => 'Access to read user sessions',
'category' => 'Auth',
],
'sessions.write' => [
'description' => 'Access to create, update, and delete user sessions',
'category' => 'Auth',
],
'teams.read' => [
'description' => 'Access to read teams',
'category' => 'Auth',
],
'teams.write' => [
'description' => 'Access to create, update, and delete teams',
'category' => 'Auth',
],
// Databases
'databases.read' => [
'description' => 'Access to read databases',
'category' => 'Databases',
],
'databases.write' => [
'description' => 'Access to create, update, and delete databases',
'category' => 'Databases',
],
'tables.read' => [
'description' => 'Access to read database tables',
'category' => 'Databases',
],
'tables.write' => [
'description' => 'Access to create, update, and delete database tables',
'category' => 'Databases',
],
'columns.read' => [
'description' => 'Access to read database table columns',
'category' => 'Databases',
],
'columns.write' => [
'description' => 'Access to create, update, and delete database table columns',
'category' => 'Databases',
],
'indexes.read' => [
'description' => 'Access to read database table indexes',
'category' => 'Databases',
],
'indexes.write' => [
'description' => 'Access to create, update, and delete database table indexes',
'category' => 'Databases',
],
'rows.read' => [
'description' => 'Access to read database table rows',
'category' => 'Databases',
],
'rows.write' => [
'description' => 'Access to create, update, and delete database table rows',
'category' => 'Databases',
],
'collections.read' => [
'description' => 'Access to read database collections',
'category' => 'Databases',
'deprecated' => true,
],
'collections.write' => [
'description' => 'Access to create, update, and delete database collections',
'category' => 'Databases',
'deprecated' => true,
],
'attributes.read' => [
'description' => 'Access to read database collection attributes',
'category' => 'Databases',
'deprecated' => true,
],
'attributes.write' => [
'description' => 'Access to create, update, and delete database collection attributes',
'category' => 'Databases',
'deprecated' => true,
],
'documents.read' => [
'description' => 'Access to read database collection documents',
'category' => 'Databases',
'deprecated' => true,
],
'documents.write' => [
'description' => 'Access to create, update, and delete database collection documents',
'category' => 'Databases',
'deprecated' => true,
],
// Storage
'buckets.read' => [
'description' => 'Access to read storage buckets',
'category' => 'Storage',
],
'buckets.write' => [
'description' => 'Access to create, update, and delete storage buckets',
'category' => 'Storage',
],
'files.read' => [
'description' => 'Access to read storage files and preview images',
'category' => 'Storage',
],
'files.write' => [
'description' => 'Access to create, update, and delete storage files',
'category' => 'Storage',
],
'tokens.read' => [
'description' => 'Access to read storage file tokens',
'category' => 'Storage',
],
'tokens.write' => [
'description' => 'Access to create, update, and delete storage file tokens',
'category' => 'Storage',
],
// Functions
'functions.read' => [
'description' => 'Access to read functions and deployments',
'category' => 'Functions',
],
'functions.write' => [
'description' => 'Access to create, update, and delete functions and deployments',
'category' => 'Functions',
],
'executions.read' => [
'description' => 'Access to read function executions',
'category' => 'Functions',
],
'executions.write' => [
'description' => 'Access to create function executions',
'category' => 'Functions',
],
// Sites
'sites.read' => [
'description' => 'Access to read sites and deployments',
'category' => 'Sites',
],
'sites.write' => [
'description' => 'Access to create, update, and delete sites and deployments',
'category' => 'Sites',
],
'log.read' => [
'description' => 'Access to read site logs',
'category' => 'Sites',
],
'log.write' => [
'description' => 'Access to update, and delete site logs',
'category' => 'Sites',
],
// Messaging
'providers.read' => [
'description' => 'Access to read messaging providers',
'category' => 'Messaging',
],
'providers.write' => [
'description' => 'Access to create, update, and delete messaging providers',
'category' => 'Messaging',
],
'topics.read' => [
'description' => 'Access to read messaging topics',
'category' => 'Messaging',
],
'topics.write' => [
'description' => 'Access to create, update, and delete messaging topics',
'category' => 'Messaging',
],
'subscribers.read' => [
'description' => 'Access to read messaging subscribers',
'category' => 'Messaging',
],
'subscribers.write' => [
'description' => 'Access to create, update, and delete messaging subscribers',
'category' => 'Messaging',
],
'targets.read' => [
'description' => 'Access to read messaging targets',
'category' => 'Messaging',
],
'targets.write' => [
'description' => 'Access to create, update, and delete messaging targets',
'category' => 'Messaging',
],
'messages.read' => [
'description' => 'Access to read messaging messages',
'category' => 'Messaging',
],
'messages.write' => [
'description' => 'Access to create, update, and delete messaging messages',
'category' => 'Messaging',
],
// Other
"webhooks.read" => [
"description" =>
"Access to read webhooks",
'category' => 'Other',
],
"webhooks.write" => [
"description" =>
"Access to create, update, and delete webhooks",
'category' => 'Other',
],
'locale.read' => [
'description' => 'Access to use Locale service',
'category' => 'Other',
],
'avatars.read' => [
'description' => 'Access to use Avatars service',
'category' => 'Other',
],
'health.read' => [
'description' => 'Access to use Health service',
'category' => 'Other',
],
'assistant.read' => [
'description' => 'Access to use Assistant service',
'category' => 'Other',
],
'migrations.read' => [
'description' => 'Access to read migrations',
'category' => 'Other',
],
'migrations.write' => [
'description' => 'Access to create, update, and delete migrations.',
'category' => 'Other',
],
// TODO: Figure out where to move those
'schedules.read' => [
'description' => 'Access to read schedules.',
'category' => 'Other',
],
'schedules.write' => [
'description' => 'Access to create, update, and delete schedules.',
'category' => 'Other',
],
'vcs.read' => [
'description' => 'Access to read resources under VCS service.',
'category' => 'Other',
],
'vcs.write' => [
'description' => 'Access to create, update, and delete resources under VCS service.',
'category' => 'Other',
],
'rules.read' => [
'description' => 'Access to read proxy rules.',
'category' => 'Other',
],
'rules.write' => [
'description' => 'Access to create, update, and delete proxy rules.',
'category' => 'Other',
],
];
+1 -1
View File
@@ -286,7 +286,7 @@ return [
'name' => 'Migrations',
'subtitle' => 'The Migrations service allows you to migrate third-party data to your Appwrite project.',
'description' => '/docs/services/migrations.md',
'controller' => 'api/migrations.php',
'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/migrations',
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -58,23 +58,11 @@ Http::get('/v1/projects/:projectId')
$response->dynamic($project, Response::MODEL_PROJECT);
});
// Backwards compatibility
Http::patch('/v1/projects/:projectId/oauth2')
->desc('Update project OAuth2')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'auth',
name: 'updateOAuth2',
description: '/docs/references/projects/update-oauth2.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
]
))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name')
->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true)
+4 -4
View File
@@ -856,7 +856,7 @@ Http::get('/v1/users/:userId/targets/:targetId')
Http::get('/v1/users/:userId/sessions')
->desc('List user sessions')
->groups(['api', 'users'])
->label('scope', 'users.read')
->label('scope', ['users.read', 'sessions.read'])
->label('sdk', new Method(
namespace: 'users',
group: 'sessions',
@@ -2314,7 +2314,7 @@ Http::post('/v1/users/:userId/sessions')
->desc('Create session')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.[sessionId].create')
->label('scope', 'users.write')
->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{request.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
@@ -2470,7 +2470,7 @@ Http::delete('/v1/users/:userId/sessions/:sessionId')
->desc('Delete user session')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.[sessionId].delete')
->label('scope', 'users.write')
->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{request.userId}')
->label('sdk', new Method(
@@ -2521,7 +2521,7 @@ Http::delete('/v1/users/:userId/sessions')
->desc('Delete user sessions')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.delete')
->label('scope', 'users.write')
->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{user.$id}')
->label('sdk', new Method(
+10 -2
View File
@@ -27,6 +27,7 @@ use Appwrite\Utopia\Request\Filters\V20 as RequestV20;
use Appwrite\Utopia\Request\Filters\V21 as RequestV21;
use Appwrite\Utopia\Request\Filters\V22 as RequestV22;
use Appwrite\Utopia\Request\Filters\V23 as RequestV23;
use Appwrite\Utopia\Request\Filters\V24 as RequestV24;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -36,6 +37,7 @@ use Appwrite\Utopia\Response\Filters\V20 as ResponseV20;
use Appwrite\Utopia\Response\Filters\V21 as ResponseV21;
use Appwrite\Utopia\Response\Filters\V22 as ResponseV22;
use Appwrite\Utopia\Response\Filters\V23 as ResponseV23;
use Appwrite\Utopia\Response\Filters\V24 as ResponseV24;
use Appwrite\Utopia\View;
use Executor\Executor;
use MaxMind\Db\Reader;
@@ -120,7 +122,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
}
if (!in_array($host, $platformHostnames)) {
if (!in_array($host, $platformHostnames) && System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'enabled') === 'enabled') {
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView);
}
@@ -397,7 +399,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'projectId' => $project->getId(),
'scopes' => $resource->getAttribute('scopes', [])
]);
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-jwt'] = '';
@@ -899,6 +901,9 @@ Http::init()
if (version_compare($requestFormat, '1.9.2', '<')) {
$request->addFilter(new RequestV23());
}
if (version_compare($requestFormat, '1.9.3', '<')) {
$request->addFilter(new RequestV24());
}
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -923,6 +928,9 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
if (version_compare($responseFormat, '1.9.3', '<')) {
$response->addFilter(new ResponseV24());
}
if (version_compare($responseFormat, '1.9.2', '<')) {
$response->addFilter(new ResponseV23());
}
+2 -1
View File
@@ -183,7 +183,8 @@ Http::init()
// Handle special app role case
if ($apiKey->getRole() === User::ROLE_APPS) {
// Disable authorization checks for project API keys
if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) {
// Dynamic supported for backwards compatibility
if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) {
$authorization->setDefaultStatus(false);
}
+11 -3
View File
@@ -44,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours
const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
const APP_CACHE_BUSTER = 4323;
const APP_VERSION_STABLE = '1.9.2';
const APP_CACHE_BUSTER = 4324;
const APP_VERSION_STABLE = '1.9.3';
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
@@ -244,6 +244,7 @@ const APP_AUTH_TYPE_KEY = 'Key';
const APP_AUTH_TYPE_ADMIN = 'Admin';
// Response related
const MAX_OUTPUT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const APP_LIMIT_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const APP_FUNCTION_LOG_LENGTH_LIMIT = 1000000;
const APP_FUNCTION_ERROR_LENGTH_LIMIT = 1000000;
// Function headers
@@ -253,9 +254,16 @@ const FUNCTION_ALLOWLIST_HEADERS_RESPONSE = ['content-type', 'content-length'];
const MESSAGE_TYPE_EMAIL = 'email';
const MESSAGE_TYPE_SMS = 'sms';
const MESSAGE_TYPE_PUSH = 'push';
// Notification types
const NOTIFICATION_TYPE_EMAIL = 'email';
const NOTIFICATION_TYPE_SMS = 'sms';
const NOTIFICATION_TYPE_PUSH = 'push';
const NOTIFICATION_TYPE_CONSOLE = 'console';
const NOTIFICATION_TYPE_WEBHOOK = 'webhook';
const RESOURCE_TYPE_ALERTS = 'alerts';
// API key types
const API_KEY_STANDARD = 'standard';
const API_KEY_DYNAMIC = 'dynamic';
const API_KEY_EPHEMERAL = 'ephemeral';
const API_KEY_ORGANIZATION = 'organization';
const API_KEY_ACCOUNT = 'account';
// Usage metrics
+97
View File
@@ -2,6 +2,7 @@
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model\Account;
use Appwrite\Utopia\Response\Model\Alert;
use Appwrite\Utopia\Response\Model\AlgoArgon2;
use Appwrite\Utopia\Response\Model\AlgoBcrypt;
use Appwrite\Utopia\Response\Model\AlgoMd5;
@@ -56,6 +57,11 @@ use Appwrite\Utopia\Response\Model\ColumnString;
use Appwrite\Utopia\Response\Model\ColumnText;
use Appwrite\Utopia\Response\Model\ColumnURL;
use Appwrite\Utopia\Response\Model\ColumnVarchar;
use Appwrite\Utopia\Response\Model\ConsoleKeyScope;
use Appwrite\Utopia\Response\Model\ConsoleKeyScopeList;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter;
use Appwrite\Utopia\Response\Model\ConsoleVariables;
use Appwrite\Utopia\Response\Model\Continent;
use Appwrite\Utopia\Response\Model\Country;
@@ -68,6 +74,7 @@ use Appwrite\Utopia\Response\Model\DetectionVariable;
use Appwrite\Utopia\Response\Model\DevKey;
use Appwrite\Utopia\Response\Model\Document as ModelDocument;
use Appwrite\Utopia\Response\Model\Embedding;
use Appwrite\Utopia\Response\Model\EphemeralKey;
use Appwrite\Utopia\Response\Model\Error;
use Appwrite\Utopia\Response\Model\ErrorDev;
use Appwrite\Utopia\Response\Model\Execution;
@@ -105,6 +112,47 @@ use Appwrite\Utopia\Response\Model\MigrationReport;
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
use Appwrite\Utopia\Response\Model\OAuth2Amazon;
use Appwrite\Utopia\Response\Model\OAuth2Apple;
use Appwrite\Utopia\Response\Model\OAuth2Auth0;
use Appwrite\Utopia\Response\Model\OAuth2Authentik;
use Appwrite\Utopia\Response\Model\OAuth2Autodesk;
use Appwrite\Utopia\Response\Model\OAuth2Bitbucket;
use Appwrite\Utopia\Response\Model\OAuth2Bitly;
use Appwrite\Utopia\Response\Model\OAuth2Box;
use Appwrite\Utopia\Response\Model\OAuth2Dailymotion;
use Appwrite\Utopia\Response\Model\OAuth2Discord;
use Appwrite\Utopia\Response\Model\OAuth2Disqus;
use Appwrite\Utopia\Response\Model\OAuth2Dropbox;
use Appwrite\Utopia\Response\Model\OAuth2Etsy;
use Appwrite\Utopia\Response\Model\OAuth2Facebook;
use Appwrite\Utopia\Response\Model\OAuth2Figma;
use Appwrite\Utopia\Response\Model\OAuth2FusionAuth;
use Appwrite\Utopia\Response\Model\OAuth2GitHub;
use Appwrite\Utopia\Response\Model\OAuth2Gitlab;
use Appwrite\Utopia\Response\Model\OAuth2Google;
use Appwrite\Utopia\Response\Model\OAuth2Keycloak;
use Appwrite\Utopia\Response\Model\OAuth2Kick;
use Appwrite\Utopia\Response\Model\OAuth2Linkedin;
use Appwrite\Utopia\Response\Model\OAuth2Microsoft;
use Appwrite\Utopia\Response\Model\OAuth2Notion;
use Appwrite\Utopia\Response\Model\OAuth2Oidc;
use Appwrite\Utopia\Response\Model\OAuth2Okta;
use Appwrite\Utopia\Response\Model\OAuth2Paypal;
use Appwrite\Utopia\Response\Model\OAuth2Podio;
use Appwrite\Utopia\Response\Model\OAuth2ProviderList;
use Appwrite\Utopia\Response\Model\OAuth2Salesforce;
use Appwrite\Utopia\Response\Model\OAuth2Slack;
use Appwrite\Utopia\Response\Model\OAuth2Spotify;
use Appwrite\Utopia\Response\Model\OAuth2Stripe;
use Appwrite\Utopia\Response\Model\OAuth2Tradeshift;
use Appwrite\Utopia\Response\Model\OAuth2Twitch;
use Appwrite\Utopia\Response\Model\OAuth2WordPress;
use Appwrite\Utopia\Response\Model\OAuth2X;
use Appwrite\Utopia\Response\Model\OAuth2Yahoo;
use Appwrite\Utopia\Response\Model\OAuth2Yandex;
use Appwrite\Utopia\Response\Model\OAuth2Zoho;
use Appwrite\Utopia\Response\Model\OAuth2Zoom;
use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\PlatformAndroid;
use Appwrite\Utopia\Response\Model\PlatformApple;
@@ -190,6 +238,7 @@ Response::setModel(new BaseList('Column Indexes List', Response::MODEL_COLUMN_IN
Response::setModel(new BaseList('Users List', Response::MODEL_USER_LIST, 'users', Response::MODEL_USER));
Response::setModel(new BaseList('Sessions List', Response::MODEL_SESSION_LIST, 'sessions', Response::MODEL_SESSION));
Response::setModel(new BaseList('Identities List', Response::MODEL_IDENTITY_LIST, 'identities', Response::MODEL_IDENTITY));
Response::setModel(new BaseList('Alerts List', Response::MODEL_ALERT_LIST, 'alerts', Response::MODEL_ALERT));
Response::setModel(new BaseList('Logs List', Response::MODEL_LOG_LIST, 'logs', Response::MODEL_LOG));
Response::setModel(new BaseList('Files List', Response::MODEL_FILE_LIST, 'files', Response::MODEL_FILE));
Response::setModel(new BaseList('Buckets List', Response::MODEL_BUCKET_LIST, 'buckets', Response::MODEL_BUCKET));
@@ -315,6 +364,7 @@ Response::setModel(new Account());
Response::setModel(new Preferences());
Response::setModel(new Session());
Response::setModel(new Identity());
Response::setModel(new Alert());
Response::setModel(new Token());
Response::setModel(new JWT());
Response::setModel(new Locale());
@@ -348,8 +398,50 @@ Response::setModel(new Execution());
Response::setModel(new Project());
Response::setModel(new Webhook());
Response::setModel(new Key());
Response::setModel(new EphemeralKey());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
Response::setModel(new OAuth2GitHub());
Response::setModel(new OAuth2Discord());
Response::setModel(new OAuth2Figma());
Response::setModel(new OAuth2Dropbox());
Response::setModel(new OAuth2Dailymotion());
Response::setModel(new OAuth2Bitbucket());
Response::setModel(new OAuth2Bitly());
Response::setModel(new OAuth2Box());
Response::setModel(new OAuth2Autodesk());
Response::setModel(new OAuth2Google());
Response::setModel(new OAuth2Zoom());
Response::setModel(new OAuth2Zoho());
Response::setModel(new OAuth2Yandex());
Response::setModel(new OAuth2X());
Response::setModel(new OAuth2WordPress());
Response::setModel(new OAuth2Twitch());
Response::setModel(new OAuth2Stripe());
Response::setModel(new OAuth2Spotify());
Response::setModel(new OAuth2Slack());
Response::setModel(new OAuth2Podio());
Response::setModel(new OAuth2Notion());
Response::setModel(new OAuth2Salesforce());
Response::setModel(new OAuth2Yahoo());
Response::setModel(new OAuth2Linkedin());
Response::setModel(new OAuth2Disqus());
Response::setModel(new OAuth2Amazon());
Response::setModel(new OAuth2Etsy());
Response::setModel(new OAuth2Facebook());
Response::setModel(new OAuth2Tradeshift());
Response::setModel(new OAuth2Paypal());
Response::setModel(new OAuth2Gitlab());
Response::setModel(new OAuth2Authentik());
Response::setModel(new OAuth2Auth0());
Response::setModel(new OAuth2FusionAuth());
Response::setModel(new OAuth2Keycloak());
Response::setModel(new OAuth2Oidc());
Response::setModel(new OAuth2Okta());
Response::setModel(new OAuth2Kick());
Response::setModel(new OAuth2Apple());
Response::setModel(new OAuth2Microsoft());
Response::setModel(new OAuth2ProviderList());
Response::setModel(new PolicyPasswordDictionary());
Response::setModel(new PolicyPasswordHistory());
Response::setModel(new PolicyPasswordPersonalData());
@@ -398,6 +490,11 @@ Response::setModel(new Rule());
Response::setModel(new Schedule());
Response::setModel(new TemplateEmail());
Response::setModel(new ConsoleVariables());
Response::setModel(new ConsoleOAuth2ProviderParameter());
Response::setModel(new ConsoleOAuth2Provider());
Response::setModel(new ConsoleOAuth2ProviderList());
Response::setModel(new ConsoleKeyScope());
Response::setModel(new ConsoleKeyScopeList());
Response::setModel(new MFAChallenge());
Response::setModel(new MFARecoveryCodes());
Response::setModel(new MFAType());
+5 -3
View File
@@ -327,9 +327,11 @@ return function (Container $container): void {
}
}
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
// Query params mirror the header fallback pattern used by ?project= and ?devKey=,
// allowing Console to embed impersonation in direct file/image URLs where headers cannot be set.
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', ''));
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', ''));
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', ''));
if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
$userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
+6
View File
@@ -159,10 +159,16 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+38 -4
View File
@@ -12,6 +12,7 @@ use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Notification;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
@@ -123,6 +124,9 @@ return function (Container $container): void {
$container->set('queueForMails', function (Publisher $publisher) {
return new Mail($publisher);
}, ['publisher']);
$container->set('queueForNotifications', function (Publisher $publisher) {
return new Notification($publisher);
}, ['publisher']);
$container->set('queueForBuilds', function (Publisher $publisher) {
return new Build($publisher);
}, ['publisher']);
@@ -204,9 +208,16 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -223,6 +234,11 @@ return function (Container $container): void {
$adapter = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter ??= new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -230,6 +246,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
@@ -571,10 +588,12 @@ return function (Container $container): void {
}
}
// Impersonation: if current user has impersonator capability and headers are set, act as another user
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
// Impersonation: if current user has impersonator capability and headers/params are set, act as another user
// Query params mirror the header fallback pattern used by ?project= and ?devKey=,
// allowing Console to embed impersonation in direct file/image URLs where headers cannot be set.
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', ''));
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', ''));
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', ''));
if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
$userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
$targetUser = null;
@@ -688,8 +707,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -1290,6 +1316,12 @@ return function (Container $container): void {
$database = new Database($adapter, $cache);
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
@@ -1312,6 +1344,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -1323,6 +1356,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
+43
View File
@@ -7,6 +7,7 @@ use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Notification;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Usage\Context;
@@ -14,6 +15,7 @@ use Appwrite\Utopia\Database\Documents\User;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
@@ -90,8 +92,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -130,8 +139,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -152,8 +168,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -210,6 +233,14 @@ return function (Container $container): void {
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database->setGlobalCollections($projectsGlobalCollections);
// For separate pools (documentsdb/vectorsdb), check their own shared tables config.
// If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
if ($databaseHost !== $dsn->getHost()) {
@@ -222,6 +253,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -233,6 +265,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -257,6 +290,11 @@ return function (Container $container): void {
return $database;
}
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -264,6 +302,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
@@ -304,6 +343,10 @@ return function (Container $container): void {
return new Mail($publisher);
}, ['publisher']);
$container->set('queueForNotifications', function (Publisher $publisher) {
return new Notification($publisher);
}, ['publisher']);
$container->set('queueForBuilds', function (Publisher $publisher) {
return new Build($publisher);
}, ['publisher']);
+10 -1
View File
@@ -45,7 +45,10 @@ use Utopia\WebSocket\Adapter;
use Utopia\WebSocket\Server;
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
require_once __DIR__ . '/init/span.php';
}
/** @var Registry $register */
$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized');
@@ -127,8 +130,14 @@ if (!function_exists('getProjectDB')) {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
exec php /usr/src/code/app/worker.php notifications "$@"
+2 -1
View File
@@ -69,6 +69,7 @@
"utopia-php/dsn": "0.2.1",
"utopia-php/http": "0.34.*",
"utopia-php/fetch": "0.5.*",
"utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
@@ -81,7 +82,7 @@
"utopia-php/queue": "0.17.*",
"utopia-php/servers": "0.3.*",
"utopia-php/registry": "0.5.*",
"utopia-php/storage": "1.0.*",
"utopia-php/storage": "2.*",
"utopia-php/system": "0.10.*",
"utopia-php/telemetry": "0.2.*",
"utopia-php/vcs": "3.*",
Generated
+117 -116
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c5ae97637fd0ec0a950044d1c33677ea",
"content-hash": "4bee36b21a57e754d2b3417e72dc9599",
"packages": [
{
"name": "adhocore/jwt",
@@ -1996,16 +1996,16 @@
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.51",
"version": "3.0.52",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "d59c94077f9c9915abb51ddb52ce85188ece1748"
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748",
"reference": "d59c94077f9c9915abb51ddb52ce85188ece1748",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
"shasum": ""
},
"require": {
@@ -2086,7 +2086,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.51"
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
},
"funding": [
{
@@ -2102,7 +2102,7 @@
"type": "tidelift"
}
],
"time": "2026-04-10T01:33:53+00:00"
"time": "2026-04-27T07:02:15+00:00"
},
{
"name": "psr/clock",
@@ -2887,7 +2887,7 @@
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@@ -2948,7 +2948,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
},
"funding": [
{
@@ -2972,7 +2972,7 @@
},
{
"name": "symfony/polyfill-php82",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
@@ -3028,7 +3028,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0"
},
"funding": [
{
@@ -3052,7 +3052,7 @@
},
{
"name": "symfony/polyfill-php83",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
@@ -3108,7 +3108,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
},
"funding": [
{
@@ -3132,16 +3132,16 @@
},
{
"name": "symfony/polyfill-php85",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
"reference": "2c408a6bb0313e6001a83628dc5506100474254e"
"reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e",
"reference": "2c408a6bb0313e6001a83628dc5506100474254e",
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"shasum": ""
},
"require": {
@@ -3188,7 +3188,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0"
},
"funding": [
{
@@ -3208,7 +3208,7 @@
"type": "tidelift"
}
],
"time": "2026-04-10T16:50:15+00:00"
"time": "2026-04-26T13:10:57+00:00"
},
{
"name": "symfony/service-contracts",
@@ -3351,16 +3351,16 @@
},
{
"name": "utopia-php/abuse",
"version": "1.2.2",
"version": "1.2.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
"reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152"
"reference": "53f4274939353522ba331f55bcff6e6011ffc56c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152",
"reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152",
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c",
"reference": "53f4274939353522ba331f55bcff6e6011ffc56c",
"shasum": ""
},
"require": {
@@ -3397,9 +3397,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/abuse/issues",
"source": "https://github.com/utopia-php/abuse/tree/1.2.2"
"source": "https://github.com/utopia-php/abuse/tree/1.2.3"
},
"time": "2026-02-02T10:43:10+00:00"
"time": "2026-04-29T11:19:08+00:00"
},
{
"name": "utopia-php/agents",
@@ -3658,16 +3658,16 @@
},
{
"name": "utopia-php/cli",
"version": "0.23.1",
"version": "0.23.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cli.git",
"reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621"
"reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
"reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
"url": "https://api.github.com/repos/utopia-php/cli/zipball/145b91fef827853bcceaa3ab8ca2b1d6faaca2ab",
"reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab",
"shasum": ""
},
"require": {
@@ -3703,9 +3703,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/cli/issues",
"source": "https://github.com/utopia-php/cli/tree/0.23.1"
"source": "https://github.com/utopia-php/cli/tree/0.23.2"
},
"time": "2026-04-05T15:27:35+00:00"
"time": "2026-04-27T09:19:04+00:00"
},
{
"name": "utopia-php/compression",
@@ -3850,16 +3850,16 @@
},
{
"name": "utopia-php/database",
"version": "5.3.22",
"version": "5.4.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
"reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7"
"reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7",
"reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7",
"url": "https://api.github.com/repos/utopia-php/database/zipball/688d9422b5ff42ac2ecc29397d94891cfd772e93",
"reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93",
"shasum": ""
},
"require": {
@@ -3903,9 +3903,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/database/issues",
"source": "https://github.com/utopia-php/database/tree/5.3.22"
"source": "https://github.com/utopia-php/database/tree/5.4.1"
},
"time": "2026-04-20T07:12:46+00:00"
"time": "2026-04-29T07:32:59+00:00"
},
{
"name": "utopia-php/detector",
@@ -4062,16 +4062,16 @@
},
{
"name": "utopia-php/domains",
"version": "1.0.5",
"version": "1.0.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/domains.git",
"reference": "0edf6bb2b07f30db849a267027077bf5abb994c6"
"reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6",
"reference": "0edf6bb2b07f30db849a267027077bf5abb994c6",
"url": "https://api.github.com/repos/utopia-php/domains/zipball/c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
"reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
"shasum": ""
},
"require": {
@@ -4118,9 +4118,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/domains/issues",
"source": "https://github.com/utopia-php/domains/tree/1.0.5"
"source": "https://github.com/utopia-php/domains/tree/1.0.6"
},
"time": "2026-03-03T09:20:50+00:00"
"time": "2026-04-29T11:08:10+00:00"
},
{
"name": "utopia-php/dsn",
@@ -4271,21 +4271,20 @@
},
{
"name": "utopia-php/http",
"version": "0.34.21",
"version": "0.34.24",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
"reference": "49a6bd3ea0d2966aa19cf707255d442675288a24"
"reference": "d1eced0627c5a9fceddf53992ed97d664b810d33"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24",
"reference": "49a6bd3ea0d2966aa19cf707255d442675288a24",
"url": "https://api.github.com/repos/utopia-php/http/zipball/d1eced0627c5a9fceddf53992ed97d664b810d33",
"reference": "d1eced0627c5a9fceddf53992ed97d664b810d33",
"shasum": ""
},
"require": {
"ext-swoole": "*",
"php": ">=8.2",
"php": ">=8.3",
"utopia-php/compression": "0.1.*",
"utopia-php/di": "0.3.*",
"utopia-php/servers": "0.3.*",
@@ -4295,11 +4294,14 @@
"require-dev": {
"doctrine/instantiator": "^1.5",
"laravel/pint": "1.*",
"phpbench/phpbench": "^1.2",
"phpstan/phpstan": "1.*",
"phpunit/phpunit": "^9.5.25",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^12.0",
"rector/rector": "^2.4",
"swoole/ide-helper": "4.8.3"
},
"suggest": {
"ext-swoole": "Required to use the Swoole server adapter (\\Utopia\\Http\\Adapter\\Swoole\\Server)."
},
"type": "library",
"autoload": {
"psr-4": {
@@ -4319,9 +4321,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
"source": "https://github.com/utopia-php/http/tree/0.34.21"
"source": "https://github.com/utopia-php/http/tree/0.34.24"
},
"time": "2026-04-19T19:44:04+00:00"
"time": "2026-04-24T12:16:53+00:00"
},
{
"name": "utopia-php/image",
@@ -4528,16 +4530,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.9.1",
"version": "1.9.5",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2"
"reference": "952a4dfe232702f80e45c35129466a8d8cb4c599"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
"reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/952a4dfe232702f80e45c35129466a8d8cb4c599",
"reference": "952a4dfe232702f80e45c35129466a8d8cb4c599",
"shasum": ""
},
"require": {
@@ -4548,7 +4550,7 @@
"php": ">=8.1",
"utopia-php/database": "5.*",
"utopia-php/dsn": "0.2.*",
"utopia-php/storage": "1.0.*"
"utopia-php/storage": "2.*"
},
"require-dev": {
"ext-pdo": "*",
@@ -4577,22 +4579,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.9.1"
"source": "https://github.com/utopia-php/migration/tree/1.9.5"
},
"time": "2026-03-25T07:05:27+00:00"
"time": "2026-04-29T11:19:13+00:00"
},
{
"name": "utopia-php/mongo",
"version": "1.0.2",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/mongo.git",
"reference": "677a21c53f7a1316c528b4b45b3fce886cee7223"
"reference": "73593682deee4696525a04e26524c1c1226e1530"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223",
"reference": "677a21c53f7a1316c528b4b45b3fce886cee7223",
"url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530",
"reference": "73593682deee4696525a04e26524c1c1226e1530",
"shasum": ""
},
"require": {
@@ -4638,9 +4640,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/mongo/issues",
"source": "https://github.com/utopia-php/mongo/tree/1.0.2"
"source": "https://github.com/utopia-php/mongo/tree/1.1.0"
},
"time": "2026-03-18T02:45:50+00:00"
"time": "2026-04-24T06:15:10+00:00"
},
{
"name": "utopia-php/platform",
@@ -5018,16 +5020,16 @@
},
{
"name": "utopia-php/storage",
"version": "1.0.1",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/storage.git",
"reference": "f014be445f0baa635d0764e1673196f412511618"
"reference": "8a2e3a86fd01aaed675884146665308c2122264e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/storage/zipball/f014be445f0baa635d0764e1673196f412511618",
"reference": "f014be445f0baa635d0764e1673196f412511618",
"url": "https://api.github.com/repos/utopia-php/storage/zipball/8a2e3a86fd01aaed675884146665308c2122264e",
"reference": "8a2e3a86fd01aaed675884146665308c2122264e",
"shasum": ""
},
"require": {
@@ -5041,9 +5043,8 @@
"utopia-php/validators": "0.2.*"
},
"require-dev": {
"laravel/pint": "1.2.*",
"phpunit/phpunit": "^9.3",
"vimeo/psalm": "4.0.1"
"laravel/pint": "^1.21",
"phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
@@ -5065,9 +5066,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/storage/issues",
"source": "https://github.com/utopia-php/storage/tree/1.0.1"
"source": "https://github.com/utopia-php/storage/tree/2.0.1"
},
"time": "2026-02-23T05:59:32+00:00"
"time": "2026-04-29T09:05:48+00:00"
},
{
"name": "utopia-php/system",
@@ -5182,16 +5183,16 @@
},
{
"name": "utopia-php/validators",
"version": "0.2.0",
"version": "0.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/validators.git",
"reference": "30b6030a5b100fc1dff34506e5053759594b2a20"
"reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20",
"reference": "30b6030a5b100fc1dff34506e5053759594b2a20",
"url": "https://api.github.com/repos/utopia-php/validators/zipball/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6",
"reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6",
"shasum": ""
},
"require": {
@@ -5221,9 +5222,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/validators/issues",
"source": "https://github.com/utopia-php/validators/tree/0.2.0"
"source": "https://github.com/utopia-php/validators/tree/0.2.2"
},
"time": "2026-01-13T09:16:51+00:00"
"time": "2026-04-27T16:30:24+00:00"
},
{
"name": "utopia-php/vcs",
@@ -5464,16 +5465,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "1.20",
"version": "1.25.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "525f0630520c95100fcdfb63c9dac859c1d02588"
"reference": "f21a556b9acdbf75bbdcdc90a078af641646eade"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588",
"reference": "525f0630520c95100fcdfb63c9dac859c1d02588",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f21a556b9acdbf75bbdcdc90a078af641646eade",
"reference": "f21a556b9acdbf75bbdcdc90a078af641646eade",
"shasum": ""
},
"require": {
@@ -5509,9 +5510,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
"source": "https://github.com/appwrite/sdk-generator/tree/1.20"
"source": "https://github.com/appwrite/sdk-generator/tree/1.25.1"
},
"time": "2026-04-20T05:45:00+00:00"
"time": "2026-04-28T11:12:22+00:00"
},
{
"name": "brianium/paratest",
@@ -5793,16 +5794,16 @@
},
{
"name": "laravel/pint",
"version": "v1.29.0",
"version": "v1.29.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
"url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": ""
},
"require": {
@@ -5813,14 +5814,14 @@
"php": "^8.2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.94.2",
"illuminate/view": "^12.54.1",
"larastan/larastan": "^3.9.3",
"laravel-zero/framework": "^12.0.5",
"friendsofphp/php-cs-fixer": "^3.95.1",
"illuminate/view": "^12.56.0",
"larastan/larastan": "^3.9.6",
"laravel-zero/framework": "^12.1.0",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest": "^3.8.6",
"shipfastlabs/agent-detector": "^1.1.0"
"shipfastlabs/agent-detector": "^1.1.3"
},
"bin": [
"builds/pint"
@@ -5857,7 +5858,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2026-03-12T15:51:39+00:00"
"time": "2026-04-20T15:26:14+00:00"
},
{
"name": "matthiasmullie/minify",
@@ -6220,11 +6221,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.50",
"version": "2.1.54",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2",
"reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"shasum": ""
},
"require": {
@@ -6269,7 +6270,7 @@
"type": "github"
}
],
"time": "2026-04-17T13:10:32+00:00"
"time": "2026-04-29T13:31:09+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -7779,7 +7780,7 @@
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -7838,7 +7839,7 @@
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -7862,16 +7863,16 @@
},
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df"
"reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df",
"reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
"reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
"shasum": ""
},
"require": {
@@ -7920,7 +7921,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
},
"funding": [
{
@@ -7940,11 +7941,11 @@
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
"time": "2026-04-26T13:13:48+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -8005,7 +8006,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
},
"funding": [
{
@@ -8029,7 +8030,7 @@
},
{
"name": "symfony/polyfill-php81",
"version": "v1.36.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
@@ -8085,7 +8086,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0"
"source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
},
"funding": [
{
@@ -8443,7 +8444,7 @@
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {},
"stability-flags": [],
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -8464,5 +8465,5 @@
"platform-dev": {
"ext-fileinfo": "*"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
+43
View File
@@ -247,6 +247,8 @@ services:
- _APP_CUSTOM_DOMAIN_DENY_LIST
- _APP_TRUSTED_HEADERS
- _APP_MIGRATION_HOST
- _TESTS_OAUTH2_GITHUB_CLIENT_ID
- _TESTS_OAUTH2_GITHUB_CLIENT_SECRET
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -804,6 +806,47 @@ services:
- _APP_OPTIONS_FORCE_HTTPS
- _APP_DATABASE_SHARED_TABLES
appwrite-worker-notifications:
entrypoint: worker-notifications
<<: [*x-logging, *x-build]
container_name: appwrite-worker-notifications
image: appwrite-dev
networks:
- appwrite
volumes:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
- redis
- maildev
- ${_APP_DB_HOST:-mariadb}
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
- _APP_SYSTEM_EMAIL_NAME
- _APP_SYSTEM_EMAIL_ADDRESS
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_SMTP_HOST
- _APP_SMTP_PORT
- _APP_SMTP_SECURE
- _APP_SMTP_USERNAME
- _APP_SMTP_PASSWORD
- _APP_LOGGING_CONFIG
- _APP_DOMAIN
- _APP_OPTIONS_FORCE_HTTPS
- _APP_DATABASE_SHARED_TABLES
appwrite-worker-messaging:
entrypoint: worker-messaging
<<: [*x-logging, *x-build]
-1
View File
@@ -1 +0,0 @@
Get all Environment Variables that are relevant for the console.
+1
View File
@@ -37,6 +37,7 @@
<directory>./tests/e2e/Services/ProjectWebhooks</directory>
<directory>./tests/e2e/Services/Messaging</directory>
<directory>./tests/e2e/Services/Migrations</directory>
<directory>./tests/e2e/Services/Notifications</directory>
<directory>./tests/e2e/Services/Project</directory>
<file>./tests/e2e/Services/Functions/FunctionsBase.php</file>
<file>./tests/e2e/Services/Functions/FunctionsCustomServerTest.php</file>
+5 -3
View File
@@ -105,7 +105,7 @@ class Key
/**
* Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name.
* Can be a stored API key or a dynamic key (JWT).
* Can be a stored API key or an ephemeral key (JWT).
*
* @throws Exception
*/
@@ -138,7 +138,9 @@ class Key
);
switch ($type) {
case API_KEY_DYNAMIC:
// Dynamic supported for backwards compatibility
case API_KEY_EPHEMERAL:
case 'dynamic':
$jwtObj = new JWT(
key: System::getEnv('_APP_OPENSSL_KEY_V1'),
algo: 'HS256',
@@ -153,7 +155,7 @@ class Key
$expired = true;
}
$name = $payload['name'] ?? 'Dynamic Key';
$name = $payload['name'] ?? 'Ephemeral Key';
$projectId = $payload['projectId'] ?? '';
$disabledMetrics = $payload['disabledMetrics'] ?? [];
$hostnameOverride = $payload['hostnameOverride'] ?? false;
-2
View File
@@ -206,8 +206,6 @@ abstract class OAuth2
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
if ($code >= 400) {
throw new Exception($response, $code);
}
+7
View File
@@ -37,6 +37,13 @@ class Authentik extends OAuth2
return 'authentik';
}
public function verifyCredentials(): void
{
if (empty($this->getAuthentikDomain())) {
throw new \Exception('Authentik endpoint is required.');
}
}
/**
* @return string
*/
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
// Reference Material
// https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints
class FusionAuth extends OAuth2
{
/**
* @var array
*/
protected array $scopes = [
'openid',
'profile',
'email',
'offline_access'
];
/**
* @var array
*/
protected array $user = [];
/**
* @var array
*/
protected array $tokens = [];
/**
* @return string
*/
public function getName(): string
{
return 'fusionauth';
}
public function verifyCredentials(): void
{
if (empty($this->getFusionAuthDomain())) {
throw new \Exception('FusionAuth endpoint is required.');
}
}
/**
* @return string
*/
public function getLoginURL(): string
{
return 'https://' . $this->getFusionAuthDomain() . '/oauth2/authorize?' . \http_build_query([
'client_id' => $this->appID,
'redirect_uri' => $this->callback,
'state' => \json_encode($this->state),
'scope' => \implode(' ', $this->getScopes()),
'response_type' => 'code'
]);
}
/**
* @param string $code
*
* @return array
*/
protected function getTokens(string $code): array
{
if (empty($this->tokens)) {
$headers = ['Content-Type: application/x-www-form-urlencoded'];
$this->tokens = \json_decode($this->request(
'POST',
'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
$headers,
\http_build_query([
'code' => $code,
'client_id' => $this->appID,
'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => \implode(' ', $this->getScopes()),
'grant_type' => 'authorization_code'
])
), true);
}
return $this->tokens;
}
/**
* @param string $refreshToken
*
* @return array
*/
public function refreshTokens(string $refreshToken): array
{
$headers = ['Content-Type: application/x-www-form-urlencoded'];
$this->tokens = \json_decode($this->request(
'POST',
'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
$headers,
\http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
if (empty($this->tokens['refresh_token'])) {
$this->tokens['refresh_token'] = $refreshToken;
}
return $this->tokens;
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserID(string $accessToken): string
{
$user = $this->getUser($accessToken);
if (isset($user['sub'])) {
return $user['sub'];
}
return '';
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserEmail(string $accessToken): string
{
$user = $this->getUser($accessToken);
if (isset($user['email'])) {
return $user['email'];
}
return '';
}
/**
* Check if the User email is verified
*
* @param string $accessToken
*
* @return bool
*/
public function isEmailVerified(string $accessToken): bool
{
$user = $this->getUser($accessToken);
if ($user['email_verified'] ?? false) {
return true;
}
return false;
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserName(string $accessToken): string
{
$user = $this->getUser($accessToken);
if (isset($user['name'])) {
return $user['name'];
}
return '';
}
/**
* @param string $accessToken
*
* @return array
*/
protected function getUser(string $accessToken): array
{
if (empty($this->user)) {
$headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
$user = $this->request('GET', 'https://' . $this->getFusionAuthDomain() . '/oauth2/userinfo', $headers);
$this->user = \json_decode($user, true);
}
return $this->user;
}
/**
* Extracts the Client Secret from the JSON stored in appSecret
*
* @return string
*/
protected function getClientSecret(): string
{
$secret = $this->getAppSecret();
return $secret['clientSecret'] ?? '';
}
/**
* Extracts the FusionAuth Domain from the JSON stored in appSecret
*
* @return string
*/
protected function getFusionAuthDomain(): string
{
$secret = $this->getAppSecret();
return $secret['fusionAuthDomain'] ?? '';
}
/**
* Decode the JSON stored in appSecret
*
* @return array
*/
protected function getAppSecret(): array
{
try {
$secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $th) {
throw new \Exception('Invalid secret');
}
return $secret;
}
}
+31
View File
@@ -3,6 +3,7 @@
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
use Utopia\Fetch\Client as FetchClient;
class Github extends OAuth2
{
@@ -219,4 +220,34 @@ class Github extends OAuth2
$repository = \json_decode($repository, true);
return $repository;
}
public function verifyCredentials(): void
{
$client = new FetchClient();
$client->addHeader('Accept', 'application/json');
$response = $client->fetch(
url: 'https://github.com/login/oauth/access_token',
method: FetchClient::METHOD_POST,
query: [
'client_id' => $this->appID,
'client_secret' => $this->appSecret,
'code' => 'intentionally-invalid-code',
'redirect_uri' => 'intentionally-invalid-redirect',
]
);
$json = \json_decode($response->getBody(), true);
if (isset($json['error']) && $json['error'] === "Not Found") {
throw new \Exception('GitHub application with provided Client ID is does not exist.');
}
if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") {
throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.');
}
// We still expect error, like redirect_uri_mismatch or bad_verification_code,
// but that indicates valid credentials
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
// Reference Material
// https://www.keycloak.org/docs/latest/securing_apps/#_oidc
class Keycloak extends OAuth2
{
/**
* @var array
*/
protected array $scopes = [
'openid',
'profile',
'email',
'offline_access'
];
/**
* @var array
*/
protected array $user = [];
/**
* @var array
*/
protected array $tokens = [];
/**
* @return string
*/
public function getName(): string
{
return 'keycloak';
}
public function verifyCredentials(): void
{
if (empty($this->getKeycloakDomain())) {
throw new \Exception('Keycloak endpoint is required.');
}
if (empty($this->getKeycloakRealm())) {
throw new \Exception('Keycloak realm name is required.');
}
}
/**
* @return string
*/
public function getLoginURL(): string
{
return $this->getRealmBaseURL() . '/protocol/openid-connect/auth?' . \http_build_query([
'client_id' => $this->appID,
'redirect_uri' => $this->callback,
'state' => \json_encode($this->state),
'scope' => \implode(' ', $this->getScopes()),
'response_type' => 'code'
]);
}
/**
* @param string $code
*
* @return array
*/
protected function getTokens(string $code): array
{
if (empty($this->tokens)) {
$headers = ['Content-Type: application/x-www-form-urlencoded'];
$this->tokens = \json_decode($this->request(
'POST',
$this->getRealmBaseURL() . '/protocol/openid-connect/token',
$headers,
\http_build_query([
'code' => $code,
'client_id' => $this->appID,
'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => \implode(' ', $this->getScopes()),
'grant_type' => 'authorization_code'
])
), true);
}
return $this->tokens;
}
/**
* @param string $refreshToken
*
* @return array
*/
public function refreshTokens(string $refreshToken): array
{
$headers = ['Content-Type: application/x-www-form-urlencoded'];
$this->tokens = \json_decode($this->request(
'POST',
$this->getRealmBaseURL() . '/protocol/openid-connect/token',
$headers,
\http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
if (empty($this->tokens['refresh_token'])) {
$this->tokens['refresh_token'] = $refreshToken;
}
return $this->tokens;
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserID(string $accessToken): string
{
$user = $this->getUser($accessToken);
if (isset($user['sub'])) {
return $user['sub'];
}
return '';
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserEmail(string $accessToken): string
{
$user = $this->getUser($accessToken);
if (isset($user['email'])) {
return $user['email'];
}
return '';
}
/**
* Check if the User email is verified
*
* @param string $accessToken
*
* @return bool
*/
public function isEmailVerified(string $accessToken): bool
{
$user = $this->getUser($accessToken);
if ($user['email_verified'] ?? false) {
return true;
}
return false;
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserName(string $accessToken): string
{
$user = $this->getUser($accessToken);
if (isset($user['name'])) {
return $user['name'];
}
return '';
}
/**
* @param string $accessToken
*
* @return array
*/
protected function getUser(string $accessToken): array
{
if (empty($this->user)) {
$headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
$user = $this->request('GET', $this->getRealmBaseURL() . '/protocol/openid-connect/userinfo', $headers);
$this->user = \json_decode($user, true);
}
return $this->user;
}
/**
* Extracts the Client Secret from the JSON stored in appSecret
*
* @return string
*/
protected function getClientSecret(): string
{
$secret = $this->getAppSecret();
return $secret['clientSecret'] ?? '';
}
/**
* Extracts the Keycloak Domain from the JSON stored in appSecret
*
* @return string
*/
protected function getKeycloakDomain(): string
{
$secret = $this->getAppSecret();
return $secret['keycloakDomain'] ?? '';
}
/**
* Extracts the Keycloak Realm from the JSON stored in appSecret
*
* @return string
*/
protected function getKeycloakRealm(): string
{
$secret = $this->getAppSecret();
return $secret['keycloakRealm'] ?? '';
}
/**
* Build the realm-scoped base URL: `https://{domain}/realms/{realm}`.
* Keycloak realm names allow spaces and other characters that must be
* percent-encoded in URLs (e.g. `my realm` `my%20realm`).
*
* @return string
*/
protected function getRealmBaseURL(): string
{
return 'https://' . $this->getKeycloakDomain() . '/realms/' . \rawurlencode($this->getKeycloakRealm());
}
/**
* Decode the JSON stored in appSecret
*
* @return array
*/
protected function getAppSecret(): array
{
try {
$secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $th) {
throw new \Exception('Invalid secret');
}
return $secret;
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
// Reference Material
// https://docs.kick.com/getting-started/generating-tokens-oauth2-flow
// https://docs.kick.com/getting-started/scopes
class Kick extends OAuth2
{
private const PKCE_STATE_KEY = '_pkce';
/**
* @var array
*/
protected array $user = [];
/**
* @var array
*/
protected array $tokens = [];
/**
* @var array
*/
protected array $scopes = [
'user:read',
];
/**
* @var string
*/
private string $pkceVerifier = '';
/**
* @return string
*/
public function getName(): string
{
return 'kick';
}
/**
* @return string
*/
public function getLoginURL(): string
{
$state = $this->state;
$state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier();
return 'https://id.kick.com/oauth/authorize?' . \http_build_query([
'response_type' => 'code',
'client_id' => $this->appID,
'redirect_uri' => $this->callback,
'scope' => \implode(' ', $this->getScopes()),
'state' => \json_encode($state),
'code_challenge' => $this->getPKCEChallenge(),
'code_challenge_method' => 'S256',
]);
}
/**
* @param string $code
*
* @return array
*/
protected function getTokens(string $code): array
{
if (empty($this->tokens)) {
$headers = ['Content-Type: application/x-www-form-urlencoded'];
$this->tokens = \json_decode($this->request(
'POST',
'https://id.kick.com/oauth/token',
$headers,
\http_build_query([
'grant_type' => 'authorization_code',
'client_id' => $this->appID,
'client_secret' => $this->appSecret,
'redirect_uri' => $this->callback,
'code_verifier' => $this->getPKCEVerifier(),
'code' => $code,
])
), true);
}
return $this->tokens;
}
/**
* @param string $refreshToken
*
* @return array
*/
public function refreshTokens(string $refreshToken): array
{
$headers = ['Content-Type: application/x-www-form-urlencoded'];
$this->tokens = \json_decode($this->request(
'POST',
'https://id.kick.com/oauth/token',
$headers,
\http_build_query([
'grant_type' => 'refresh_token',
'client_id' => $this->appID,
'client_secret' => $this->appSecret,
'refresh_token' => $refreshToken,
])
), true);
if (empty($this->tokens['refresh_token'])) {
$this->tokens['refresh_token'] = $refreshToken;
}
return $this->tokens;
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserID(string $accessToken): string
{
$user = $this->getUser($accessToken);
return isset($user['user_id']) ? (string)$user['user_id'] : '';
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserEmail(string $accessToken): string
{
$user = $this->getUser($accessToken);
return $user['email'] ?? '';
}
/**
* Check if the OAuth email is verified.
*
* Kick only returns an email when the user has granted the `user:read`
* scope and the account email is verified, so a non-empty email is
* treated as verified.
*
* @param string $accessToken
*
* @return bool
*/
public function isEmailVerified(string $accessToken): bool
{
return !empty($this->getUserEmail($accessToken));
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserName(string $accessToken): string
{
$user = $this->getUser($accessToken);
return $user['name'] ?? '';
}
/**
* @param string $accessToken
*
* @return array
*/
protected function getUser(string $accessToken): array
{
if (empty($this->user)) {
$headers = ['Authorization: Bearer ' . $accessToken];
$response = \json_decode($this->request(
'GET',
'https://api.kick.com/public/v1/users',
$headers
), true);
$this->user = $response['data'][0] ?? [];
}
return $this->user;
}
/**
* Extract the PKCE verifier from the state on the callback so the same
* value generated in getLoginURL() can be sent to the token endpoint.
*
* @param string $state
*
* @return array<string, mixed>|null
*/
public function parseState(string $state): ?array
{
$parsed = \json_decode($state, true);
if (!\is_array($parsed)) {
return null;
}
$verifier = $parsed[self::PKCE_STATE_KEY] ?? null;
if (\is_string($verifier)) {
$this->pkceVerifier = $verifier;
}
unset($parsed[self::PKCE_STATE_KEY]);
return $parsed;
}
private function getPKCEVerifier(): string
{
if ($this->pkceVerifier === '') {
$this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '=');
}
return $this->pkceVerifier;
}
private function getPKCEChallenge(): string
{
return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '=');
}
}
+9 -2
View File
@@ -36,6 +36,13 @@ class Microsoft extends OAuth2
return 'microsoft';
}
public function verifyCredentials(): void
{
if (empty($this->getTenantID())) {
throw new \Exception('Microsoft tenant is required.');
}
}
/**
* @return string
*/
@@ -201,7 +208,7 @@ class Microsoft extends OAuth2
}
/**
* Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback
* Extracts the Tenant Id from the JSON stored in appSecret.
*
* @return string
*/
@@ -209,6 +216,6 @@ class Microsoft extends OAuth2
{
$secret = $this->getAppSecret();
return $secret['tenantID'] ?? 'common';
return $secret['tenantID'] ?? '';
}
}
+3
View File
@@ -21,6 +21,9 @@ class Event
public const MAILS_QUEUE_NAME = 'v1-mails';
public const MAILS_CLASS_NAME = 'MailsV1';
public const NOTIFICATIONS_QUEUE_NAME = 'v1-notifications';
public const NOTIFICATIONS_CLASS_NAME = 'NotificationsV1';
public const FUNCTIONS_QUEUE_NAME = 'v1-functions';
public const FUNCTIONS_CLASS_NAME = 'FunctionsV1';
public const FUNCTIONS_QUEUE_TTL = 60 * 60 * 24 * 7; // 7 days
+322
View File
@@ -0,0 +1,322 @@
<?php
namespace Appwrite\Event;
use Utopia\Config\Config;
use Utopia\Queue\Publisher;
use Utopia\System\System;
class Notification extends Event
{
protected string $recipient = '';
protected string $name = '';
protected string $subject = '';
protected string $body = '';
protected string $preview = '';
protected array $variables = [];
protected string $bodyTemplate = '';
protected array $attachment = [];
/**
* Recipients to deliver the notification to.
*
* Each entry has an `address` (channel-specific identifier email,
* userId, or webhook URL) and a `channel`. Webhook recipients may
* additionally carry an optional `signatureKey`; when set, the
* webhook adapter signs the request body with HMAC-SHA256 and adds
* the `X-Appwrite-Webhook-Signature` header. Without a key the
* payload is sent unsigned. Optional `userId` and `teamId` identify
* the owner of the alert (used by C2/C3 budget/limit alerts).
*
* @var array<int, array{address: string, channel: string, signatureKey?: string, userId?: string, teamId?: string}>
*/
protected array $recipients = [];
/**
* @var array<string>
*/
protected array $channels = [];
protected string $template = '';
protected array $templateParams = [];
protected string $deduplicationKey = '';
/**
* @var array<string>
*/
protected array $permissions = [];
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
$this
->setQueue(System::getEnv('_APP_NOTIFICATIONS_QUEUE_NAME', Event::NOTIFICATIONS_QUEUE_NAME))
->setClass(System::getEnv('_APP_NOTIFICATIONS_CLASS_NAME', Event::NOTIFICATIONS_CLASS_NAME));
}
public function setSubject(string $subject): self
{
$this->subject = $subject;
return $this;
}
public function getSubject(): string
{
return $this->subject;
}
public function setRecipient(string $recipient): self
{
$this->recipient = $recipient;
return $this;
}
public function getRecipient(): string
{
return $this->recipient;
}
public function setBody(string $body): self
{
$this->body = $body;
return $this;
}
public function getBody(): string
{
return $this->body;
}
public function setPreview(string $preview): self
{
$this->preview = $preview;
return $this;
}
public function getPreview(): string
{
return $this->preview;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setBodyTemplate(string $bodyTemplate): self
{
$this->bodyTemplate = $bodyTemplate;
return $this;
}
public function getBodyTemplate(): string
{
return $this->bodyTemplate;
}
public function getVariables(): array
{
return $this->variables;
}
public function setVariables(array $variables): self
{
$this->variables = $variables;
return $this;
}
public function appendVariables(array $variables): self
{
$this->variables = \array_merge($this->variables, $variables);
return $this;
}
public function setAttachment(string $content, string $filename, string $encoding = 'base64', string $type = 'plain/text'): self
{
$this->attachment = [
'content' => \base64_encode($content),
'filename' => $filename,
'encoding' => $encoding,
'type' => $type,
];
return $this;
}
public function getAttachment(): array
{
return $this->attachment;
}
public function resetAttachment(): self
{
$this->attachment = [];
return $this;
}
/**
* @param array<int, array{address: string, channel: string, signatureKey?: string, userId?: string, teamId?: string}> $recipients
*/
public function setRecipients(array $recipients): self
{
$this->recipients = $recipients;
return $this;
}
/**
* @return array<int, array{address: string, channel: string, signatureKey?: string, userId?: string, teamId?: string}>
*/
public function getRecipients(): array
{
return $this->recipients;
}
public function addRecipient(
string $address,
string $channel = NOTIFICATION_TYPE_EMAIL,
?string $signatureKey = null,
?string $userId = null,
?string $teamId = null,
): self {
$recipient = ['address' => $address, 'channel' => $channel];
if ($signatureKey !== null && $signatureKey !== '') {
$recipient['signatureKey'] = $signatureKey;
}
if ($userId !== null && $userId !== '') {
$recipient['userId'] = $userId;
}
if ($teamId !== null && $teamId !== '') {
$recipient['teamId'] = $teamId;
}
$this->recipients[] = $recipient;
return $this;
}
/**
* @param array<string> $channels
*/
public function setChannels(array $channels): self
{
$this->channels = $channels;
return $this;
}
/**
* @return array<string>
*/
public function getChannels(): array
{
return $this->channels;
}
public function setTemplate(string $template): self
{
$this->template = $template;
return $this;
}
public function getTemplate(): string
{
return $this->template;
}
public function setTemplateParams(array $params): self
{
$this->templateParams = $params;
return $this;
}
public function getTemplateParams(): array
{
return $this->templateParams;
}
public function setDeduplicationKey(string $deduplicationKey): self
{
$this->deduplicationKey = $deduplicationKey;
return $this;
}
public function getDeduplicationKey(): string
{
return $this->deduplicationKey;
}
/**
* @param array<string> $permissions
*/
public function setPermissions(array $permissions): self
{
$this->permissions = $permissions;
return $this;
}
/**
* @return array<string>
*/
public function getPermissions(): array
{
return $this->permissions;
}
public function reset(): self
{
$this->project = null;
$this->recipient = '';
$this->name = '';
$this->subject = '';
$this->body = '';
$this->variables = [];
$this->bodyTemplate = '';
$this->attachment = [];
$this->recipients = [];
$this->channels = [];
$this->template = '';
$this->templateParams = [];
$this->deduplicationKey = '';
$this->permissions = [];
return $this;
}
protected function preparePayload(): array
{
$platform = $this->platform;
if (empty($platform)) {
$platform = Config::getParam('platform', []);
}
$recipients = $this->recipients;
if (empty($recipients) && !empty($this->recipient)) {
$recipients = [[
'address' => $this->recipient,
'channel' => NOTIFICATION_TYPE_EMAIL,
]];
}
return [
'project' => $this->project,
'recipient' => $this->recipient,
'recipients' => $recipients,
'channels' => $this->channels,
'template' => $this->template,
'templateParams' => $this->templateParams,
'deduplicationKey' => $this->deduplicationKey,
'permissions' => $this->permissions,
'name' => $this->name,
'subject' => $this->subject,
'bodyTemplate' => $this->bodyTemplate,
'body' => $this->body,
'preview' => $this->preview,
'variables' => $this->variables,
'attachment' => $this->attachment,
'events' => Event::generateEvents($this->getEvent(), $this->getParams()),
'platform' => $platform,
];
}
}
+1
View File
@@ -95,6 +95,7 @@ abstract class Migration
'1.9.0' => 'V24',
'1.9.1' => 'V24',
'1.9.2' => 'V24',
'1.9.3' => 'V24',
];
/**
+2
View File
@@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core;
use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Health;
use Appwrite\Platform\Modules\Migrations;
use Appwrite\Platform\Modules\Project;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
@@ -39,6 +40,7 @@ class Appwrite extends Platform
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
$this->addModule(new Webhooks\Module());
$this->addModule(new Migrations\Module());
$this->addModule(new Project\Module());
}
}
@@ -62,7 +62,6 @@ class Get extends Action
curl_setopt_array($ch, $options);
curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
return $errno === 0;
}
@@ -0,0 +1,75 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Alerts\Read;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Update extends Action
{
use HTTP;
public static function getName(): string
{
return 'updateAlertRead';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/account/alerts/:alertId/read')
->desc('Mark alert read')
->groups(['api', 'account'])
->label('scope', 'account')
->label('sdk', new Method(
namespace: 'account',
group: 'alerts',
name: 'updateAlertRead',
description: '/docs/references/account/update-alert-read.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ALERT,
)
]
))
->param('alertId', '', new UID(), 'Alert ID.')
->inject('response')
->inject('dbForPlatform')
->inject('user')
->callback($this->action(...));
}
public function action(
string $alertId,
Response $response,
Database $dbForPlatform,
Document $user,
): void {
$alert = $dbForPlatform->getDocument('alerts', $alertId);
if ($alert->isEmpty()) {
throw new Exception(Exception::DOCUMENT_NOT_FOUND);
}
if ($alert->getAttribute('userId') !== $user->getId()) {
throw new Exception(Exception::USER_UNAUTHORIZED);
}
$updated = $dbForPlatform->updateDocument('alerts', $alertId, new Document([
'read' => true,
]));
$response->dynamic($updated, Response::MODEL_ALERT);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Alerts\Track;
use Ahc\Jwt\JWT;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getAlertTrack';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/account/alerts/:alertId/track')
->desc('Track alert')
->groups(['api', 'account'])
->label('scope', 'public')
->label('sdk', new Method(
namespace: 'account',
group: 'alerts',
name: 'getAlertTrack',
description: '/docs/references/account/get-alert-track.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_NONE,
),
],
contentType: ContentType::IMAGE_PNG,
))
->param('alertId', '', new UID(), 'Alert ID.')
->param('jwt', '', new Text(2048, 0), 'Tracking token.', true)
->inject('response')
->inject('dbForPlatform')
->inject('authorization')
->callback($this->action(...));
}
public function action(
string $alertId,
string $jwt,
Response $response,
Database $dbForPlatform,
Authorization $authorization,
): void {
$secret = System::getEnv('_APP_OPENSSL_KEY_V1');
if ($secret !== '' && $jwt !== '') {
try {
$decoder = new JWT($secret, 'HS256', 2592000, 0);
$decoded = $decoder->decode($jwt);
if (
isset($decoded['alertId'], $decoded['userId'])
&& $decoded['alertId'] === $alertId
) {
$authorization->skip(function () use ($dbForPlatform, $alertId, $decoded) {
$alert = $dbForPlatform->getDocument('alerts', $alertId);
if (
!$alert->isEmpty()
&& $alert->getAttribute('userId') === $decoded['userId']
&& $alert->getAttribute('read') !== true
) {
$dbForPlatform->updateDocument('alerts', $alertId, new Document([
'read' => true,
]));
}
});
}
} catch (\Throwable) {
// Silent fail — never reveal JWT validity through response status
}
}
// 1x1 transparent PNG (canonical 67-byte payload)
$pixel = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==');
$response
->setContentType('image/png')
->addHeader('Cache-Control', 'no-store')
->send($pixel);
}
}
@@ -0,0 +1,113 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Alerts;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Alerts;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listAlerts';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/account/alerts')
->desc('List alerts')
->groups(['api', 'account'])
->label('scope', 'account')
->label('sdk', new Method(
namespace: 'account',
group: 'alerts',
name: 'listAlerts',
description: '/docs/references/account/list-alerts.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ALERT_LIST,
)
]
))
->param('queries', [], new Alerts(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Alerts::ALLOWED_ATTRIBUTES), true)
->inject('response')
->inject('dbForPlatform')
->inject('user')
->callback($this->action(...));
}
/**
* @param array<string> $queries
*/
public function action(
array $queries,
Response $response,
Database $dbForPlatform,
Document $user
): void {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$queries[] = Query::equal('userId', [$user->getId()]);
/**
* Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries
*/
$cursor = \array_filter($queries, function ($query) {
return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]);
});
$cursor = reset($cursor);
if ($cursor) {
/** @var Query $cursor */
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$alertId = $cursor->getValue();
$cursorDocument = $dbForPlatform->getDocument('alerts', $alertId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Alert '{$alertId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$results = $dbForPlatform->find('alerts', $queries);
$total = $dbForPlatform->count('alerts', $filterQueries, APP_LIMIT_COUNT);
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'alerts' => $results,
'total' => $total,
]), Response::MODEL_ALERT_LIST);
}
}
@@ -12,6 +12,9 @@ use Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes\Create as C
use Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes\Get as GetRecoveryCodes;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes\Update as UpdateRecoveryCodes;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Update as UpdateMfa;
use Appwrite\Platform\Modules\Account\Http\Alerts\Read\Update as UpdateAlertRead;
use Appwrite\Platform\Modules\Account\Http\Alerts\Track\Get as TrackAlert;
use Appwrite\Platform\Modules\Account\Http\Alerts\XList as ListAlerts;
use Utopia\Platform\Service;
class Http extends Service
@@ -29,6 +32,9 @@ class Http extends Service
->addAction(UpdateRecoveryCodes::getName(), new UpdateRecoveryCodes())
->addAction(GetRecoveryCodes::getName(), new GetRecoveryCodes())
->addAction(CreateChallenge::getName(), new CreateChallenge())
->addAction(UpdateChallenge::getName(), new UpdateChallenge());
->addAction(UpdateChallenge::getName(), new UpdateChallenge())
->addAction(ListAlerts::getName(), new ListAlerts())
->addAction(UpdateAlertRead::getName(), new UpdateAlertRead())
->addAction(TrackAlert::getName(), new TrackAlert());
}
}
@@ -94,9 +94,12 @@ class Get extends Action
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED);
}
$body = $res->getBody();
$doc = new DOMDocument();
$doc->strictErrorChecking = false;
@$doc->loadHTML($res->getBody());
if (!empty($body)) {
@$doc->loadHTML($body);
}
$links = $doc->getElementsByTagName('link');
$outputHref = '';
@@ -85,8 +85,6 @@ class Create extends Action
curl_exec($ch);
curl_close($ch);
$response->chunk('', true);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Appwrite\Platform\Modules\Console\Http\OAuth2Providers;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base as OAuth2Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listOAuth2Providers';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/console/oauth2-providers')
->desc('List OAuth2 providers')
->groups(['api'])
->label('scope', 'public')
->label('sdk', new Method(
namespace: 'console',
group: 'console',
name: 'listOAuth2Providers',
description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST,
)
],
contentType: ContentType::JSON
))
->inject('response')
->callback($this->action(...));
}
public function action(Response $response): void
{
$providersConfig = Config::getParam('oAuthProviders', []);
$actions = OAuth2Base::getProviderActions();
$providers = [];
foreach ($providersConfig as $providerId => $config) {
$updateClass = $actions[$providerId] ?? null;
if ($updateClass === null) {
continue;
}
if (!($config['enabled'] ?? false)) {
continue;
}
if ($config['mock'] ?? false) {
continue;
}
$providers[] = new Document([
'$id' => $providerId,
'parameters' => $updateClass::getParameters(),
]);
}
$response->dynamic(new Document([
'total' => \count($providers),
'oAuth2Providers' => $providers,
]), Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST);
}
}
@@ -0,0 +1,69 @@
<?php
namespace Appwrite\Platform\Modules\Console\Http\Scopes\Key;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listConsoleProjectScopes';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/console/scopes/project')
->desc('List project scopes')
->groups(['api'])
->label('scope', 'public')
->label('sdk', new Method(
namespace: 'console',
group: 'console',
name: 'listProjectScopes',
description: 'List all scopes available for project API keys, along with a description for each scope.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST,
)
],
contentType: ContentType::JSON
))
->inject('response')
->callback($this->action(...));
}
public function action(Response $response): void
{
$scopesConfig = Config::getParam('projectScopes', []);
$scopes = [];
foreach ($scopesConfig as $scopeId => $scope) {
$scopes[] = new Document([
'$id' => $scopeId,
'description' => $scope['description'] ?? '',
'category' => $scope['category'] ?? '',
'deprecated' => $scope['deprecated'] ?? false,
]);
}
$response->dynamic(new Document([
'total' => \count($scopes),
'scopes' => $scopes,
]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
}
}
@@ -36,7 +36,7 @@ class Get extends Action
namespace: 'console',
group: 'console',
name: 'variables',
description: '/docs/references/console/variables.md',
description: 'Get all Environment Variables that are relevant for the console.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Console\Services;
use Appwrite\Platform\Modules\Console\Http\Assistant\Create as CreateAssistantQuery;
use Appwrite\Platform\Modules\Console\Http\Init\API;
use Appwrite\Platform\Modules\Console\Http\Init\Web;
use Appwrite\Platform\Modules\Console\Http\OAuth2Providers\XList as ListOAuth2Providers;
use Appwrite\Platform\Modules\Console\Http\Redirects\Auth\Get as RedirectAuth;
use Appwrite\Platform\Modules\Console\Http\Redirects\Card\Get as RedirectCard;
use Appwrite\Platform\Modules\Console\Http\Redirects\Invite\Get as RedirectInvite;
@@ -14,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco
use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister;
use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot;
use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability;
use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes;
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
use Utopia\Platform\Service;
@@ -28,6 +30,8 @@ class Http extends Service
$this->addAction(Web::getName(), new Web());
$this->addAction(GetVariables::getName(), new GetVariables());
$this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
$this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
@@ -34,7 +34,7 @@ class Create extends BooleanCreate
->desc('Create boolean column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -34,7 +34,7 @@ class Update extends BooleanUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/boolean/:key')
->desc('Update boolean column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -34,7 +34,7 @@ class Create extends DatetimeCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime')
->desc('Create datetime column')
->groups(['api', 'database'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -35,7 +35,7 @@ class Update extends DatetimeUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime/:key')
->desc('Update dateTime column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -33,7 +33,7 @@ class Delete extends AttributesDelete
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
->desc('Delete column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.delete')
@@ -34,7 +34,7 @@ class Create extends EmailCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email')
->desc('Create email column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -35,7 +35,7 @@ class Update extends EmailUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email/:key')
->desc('Update email column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -35,7 +35,7 @@ class Create extends EnumCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum')
->desc('Create enum column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -36,7 +36,7 @@ class Update extends EnumUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum/:key')
->desc('Update enum column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -34,7 +34,7 @@ class Create extends FloatCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float')
->desc('Create float column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -35,7 +35,7 @@ class Update extends FloatUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float/:key')
->desc('Update float column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -42,7 +42,7 @@ class Get extends AttributesGet
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
->desc('Get column')
->groups(['api', 'database'])
->label('scope', ['tables.read', 'collections.read'])
->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
@@ -34,7 +34,7 @@ class Create extends IPCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip')
->desc('Create IP address column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -35,7 +35,7 @@ class Update extends IPUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip/:key')
->desc('Update IP address column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -34,7 +34,7 @@ class Create extends IntegerCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer')
->desc('Create integer column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -35,7 +35,7 @@ class Update extends IntegerUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer/:key')
->desc('Update integer column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -35,7 +35,7 @@ class Create extends LineCreate
->desc('Create line column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -35,7 +35,7 @@ class Update extends LineUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/line/:key')
->desc('Update line column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -33,7 +33,7 @@ class Create extends LongtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext')
->desc('Create longtext column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -34,7 +34,7 @@ class Update extends LongtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext/:key')
->desc('Update longtext column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -33,7 +33,7 @@ class Create extends MediumtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext')
->desc('Create mediumtext column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -34,7 +34,7 @@ class Update extends MediumtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext/:key')
->desc('Update mediumtext column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -35,7 +35,7 @@ class Create extends PointCreate
->desc('Create point column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -35,7 +35,7 @@ class Update extends PointUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/point/:key')
->desc('Update point column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -35,7 +35,7 @@ class Create extends PolygonCreate
->desc('Create polygon column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -35,7 +35,7 @@ class Update extends PolygonUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/polygon/:key')
->desc('Update polygon column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -34,7 +34,7 @@ class Create extends RelationshipCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship')
->desc('Create relationship column')
->groups(['api', 'database'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -34,7 +34,7 @@ class Update extends RelationshipUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key/relationship')
->desc('Update relationship column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -37,7 +37,7 @@ class Create extends StringCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string')
->desc('Create string column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -37,7 +37,7 @@ class Update extends StringUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string/:key')
->desc('Update string column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -33,7 +33,7 @@ class Create extends TextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text')
->desc('Create text column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -34,7 +34,7 @@ class Update extends TextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text/:key')
->desc('Update text column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -34,7 +34,7 @@ class Create extends URLCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url')
->desc('Create URL column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -35,7 +35,7 @@ class Update extends URLUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url/:key')
->desc('Update URL column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -35,7 +35,7 @@ class Create extends VarcharCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar')
->desc('Create varchar column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -36,7 +36,7 @@ class Update extends VarcharUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar/:key')
->desc('Update varchar column')
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
@@ -33,7 +33,7 @@ class XList extends AttributesXList
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns')
->desc('List columns')
->groups(['api', 'database'])
->label('scope', ['tables.read', 'collections.read'])
->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
@@ -37,7 +37,7 @@ class Create extends IndexCreate
->desc('Create index')
->groups(['api', 'database'])
->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create')
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'indexes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'index.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -36,7 +36,7 @@ class Delete extends IndexDelete
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key')
->desc('Delete index')
->groups(['api', 'database'])
->label('scope', ['tables.write', 'collections.write'])
->label('scope', ['tables.write', 'collections.write', 'indexes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].update')
->label('audits.event', 'index.delete')
@@ -32,7 +32,7 @@ class Get extends IndexGet
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key')
->desc('Get index')
->groups(['api', 'database'])
->label('scope', ['tables.read', 'collections.read'])
->label('scope', ['tables.read', 'collections.read', 'indexes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
@@ -33,7 +33,7 @@ class XList extends IndexXList
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes')
->desc('List indexes')
->groups(['api', 'database'])
->label('scope', ['tables.read', 'collections.read'])
->label('scope', ['tables.read', 'collections.read', 'indexes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
@@ -175,15 +175,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE);
}
// TODO remove the condition that checks `$end === $fileSize` in next breaking version
if ($end === $fileSize - 1 || $end === $fileSize) {
//if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk
$chunks = $chunk = -1;
} else {
// Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart)
$chunks = (int) ceil($fileSize / ($end + 1 - $start));
$chunk = (int) ($start / ($end + 1 - $start)) + 1;
}
$chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE);
$chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1;
}
if (!$fileSizeValidator->isValid($fileSize) && $functionSizeLimit !== 0) { // Check if file size is exceeding allowed limit
@@ -202,15 +195,14 @@ class Create extends Action
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
if (!$deployment->isEmpty()) {
$chunks = $deployment->getAttribute('sourceChunksTotal', 1);
$uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
$metadata = $deployment->getAttribute('sourceMetadata', []);
if ($chunk === -1) {
$chunk = $chunks;
}
} else {
// Guard against manually setting range header for single chunk upload
if ($chunks === -1) {
$chunks = 1;
$chunk = 1;
if ($uploaded === $chunks) {
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($deployment, Response::MODEL_DEPLOYMENT);
return;
}
}
@@ -258,6 +250,8 @@ class Create extends Action
'sourcePath' => $path,
'sourceSize' => $fileSize,
'totalSize' => $fileSize,
'sourceChunksTotal' => $chunks,
'sourceChunksUploaded' => $chunksUploaded,
'activate' => $activate,
'sourceMetadata' => $metadata,
'type' => $type
@@ -272,6 +266,7 @@ class Create extends Action
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceSize' => $fileSize,
'sourceChunksUploaded' => $chunksUploaded,
'sourceMetadata' => $metadata,
]));
}
@@ -60,7 +60,7 @@ class Create extends Base
->setHttpPath('/v1/functions/:functionId/executions')
->desc('Create execution')
->groups(['api', 'functions'])
->label('scope', 'execution.write')
->label('scope', ['executions.write', 'execution.write'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('event', 'functions.[functionId].executions.[executionId].create')
->label('sdk', new Method(
@@ -228,7 +228,7 @@ class Create extends Base
$executionId = ID::unique();
$headers['x-appwrite-execution-id'] = $executionId;
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-id'] = $user->getId();
$headers['x-appwrite-user-jwt'] = $jwt;
@@ -35,7 +35,7 @@ class Delete extends Base
->setHttpPath('/v1/functions/:functionId/executions/:executionId')
->desc('Delete execution')
->groups(['api', 'functions'])
->label('scope', 'execution.write')
->label('scope', ['executions.write', 'execution.write'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('event', 'functions.[functionId].executions.[executionId].delete')
->label('audits.event', 'executions.delete')
@@ -31,7 +31,7 @@ class Get extends Base
->setHttpPath('/v1/functions/:functionId/executions/:executionId')
->desc('Get execution')
->groups(['api', 'functions'])
->label('scope', 'execution.read')
->label('scope', ['executions.read', 'execution.read'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('sdk', new Method(
namespace: 'functions',

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