Compare commits

...
Author SHA1 Message Date
loks0nandClaude Sonnet 4.5 b6793dc0b5 Fix remaining blocked account test assertions
Update testBlockedAccount and testSelfBlockedAccount to expect 403
instead of 401 for blocked user responses. These were missed in the
previous test assertion update.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-06 17:43:36 +00:00
loks0nandClaude Sonnet 4.5 a2ad25a00a Update test assertions for blocked user status code
Change expected status code from 401 to 403 for USER_BLOCKED errors
to match the semantic change in error codes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-06 17:30:51 +00:00
loks0nandClaude Sonnet 4.5 16ad05792d Change blocked user/resource errors from 401 to 403
- Update USER_BLOCKED from 401 to 403 and simplify description
- Update GENERAL_RESOURCE_BLOCKED from 401 to 403

Rationale: 403 Forbidden is the correct HTTP status for authorization
failures where the user is authenticated but not permitted access.
401 Unauthorized is for authentication failures.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-06 17:30:51 +00:00
Luke B. SilverandGitHub e5f0bc2df6 Merge pull request #11457 from appwrite/fix-migration-errors
Fix migration errors
2026-03-06 17:20:39 +00:00
Chirag AggarwalandGitHub 0d3d14d9e1 Merge pull request #11465 from appwrite/optimize-update-document-calls
perf: optimize updateDocument() calls to use sparse documents
2026-03-06 21:17:31 +05:30
Chirag Aggarwal f2826189c6 fix: remove asserting z does not exist in truncated logs 2026-03-06 18:24:04 +05:30
Chirag Aggarwal 8b026d3459 perf: optimize updateDocument() calls to use sparse documents
Optimize updateDocument() calls across the codebase to pass only changed
attributes as sparse Document objects rather than full documents. This is
more efficient because updateDocument() internally performs array_merge().

Changes:
- Updated 58 files to use sparse Document objects
- Added Performance Patterns section to AGENTS.md with optimization guidelines
- Applied pattern to Workers, Functions, Sites, Teams, VCS modules
- Updated app/controllers/api files (account, users, messaging)
- Updated app infrastructure files (realtime, general, init/resources, shared/api)

Exceptions maintained:
- Migration files (need full document updates by design)
- Cases with 6+ attributes (marginal benefit)
- Complex nested relationship logic
2026-03-06 17:05:19 +05:30
Jake Barnby 2306072dda Merge branch 'fix-ce-test-flakes' into 1.8.x 2026-03-06 14:18:37 +13:00
Jake BarnbyandClaude Opus 4.6 91edf82060 fix: database index length assertion and email race in password recovery
- Remove specific index length number from testPatchAttribute assertion
  since the value differs between shared/non-shared table modes (767 vs
  768) and the console API returns the console project's value, not the
  user project's
- Use getLastEmailByAddress in testPasswordRecoveryUrlParams to avoid
  retrieving emails from parallel test classes sharing the same maildev

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:41:35 +13:00
Chirag AggarwalandGitHub 3fc50e4d8d Merge pull request #11464 from appwrite/fix/schedule-base-project-access-update
fix: Update in-memory project document after accessedAt update
2026-03-05 20:07:49 +05:30
Chirag Aggarwal 71dff441ed fix: Update in-memory project document after accessedAt update
Previously, the updateProjectAccess method updated the database with the new

accessedAt timestamp but did not update the in-memory project document. This

caused the if statement to constantly evaluate to true on subsequent calls,

triggering unnecessary database updates.
2026-03-05 20:03:42 +05:30
Jake BarnbyandClaude Opus 4.6 65780d75f9 fix: test flakes — correct index length fallback, add retries
- Fix getMaxIndexLength() fallback from 768 to 767 to match MariaDB's
  actual InnoDB index key limit
- Add retry logic to getConsoleVariables() to handle intermittent 401s
- Add retry logic to API key creation in ProjectCustom to prevent
  cascading 401 failures in test methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:01:23 +13:00
Jake BarnbyandClaude Opus 4.6 ee107d30b3 fix: make Projects tests parallel-safe
- Use getLastEmailByAddress for SMTP tests instead of getLastEmail(2) to
  avoid shared mail server state issues under parallel execution
- Add retry logic to setupProject, setupProjectData, and
  setupScheduleProjectData for intermittent 401 errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:20:09 +13:00
Shmuel FogelandGitHub 499ca71c28 Merge pull request #11459 from appwrite/stop-on-error
Phpunit stop on error
2026-03-05 14:06:04 +02:00
fogelito cfe60dd2a2 Merge branch '1.8.x' of https://github.com/appwrite/appwrite into stop-on-error 2026-03-05 12:32:33 +02:00
Jake BarnbyandClaude Opus 4.6 ab85942276 fix: add retry and session verification to getRoot() for parallel tests
getRoot() now retries up to 5 times with session verification to handle
race conditions when multiple paratest workers initialize simultaneously.
Previously, if account creation or session creation failed under load,
all subsequent test requests would fail with 401.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:19:54 +13:00
Chirag AggarwalandGitHub 0214f22d20 Merge pull request #11461 from appwrite/fix/stale-project-overwrites-oauth
fix: prevent stale project documents from overwriting OAuth providers
2026-03-05 15:46:51 +05:30
Chirag Aggarwal e0269e268f fix: re-read project from DB before updating accessedAt to prevent stale writes
Stale in-memory project documents in ScheduleBase (and request-scoped
copies in api.php/general.php) were overwriting current DB state when
updateProjectAccess triggered. Because Database::updateDocument uses
array_merge with the passed document taking priority, cached projects
missing recent OAuth provider changes would silently disable them.

Now fetches a fresh project document from the DB before writing, so only
accessedAt is updated without clobbering other fields.
2026-03-05 15:44:21 +05:30
fogelito a4844599c6 Merge branch '1.8.x' of https://github.com/appwrite/appwrite into stop-on-error 2026-03-05 12:00:07 +02:00
Shmuel FogelandGitHub ce876adb3a Merge pull request #11460 from appwrite/stats-count-buckets
Stats count image transformations errors
2026-03-05 11:58:40 +02:00
fogelito 59edcf1934 Merge branch '1.8.x' of https://github.com/appwrite/appwrite into stop-on-error 2026-03-05 11:42:16 +02:00
fogelito 5821832da6 count_for_image_transformations_bucket_ 2026-03-05 11:08:45 +02:00
Chirag AggarwalandGitHub d4df2c51de Merge pull request #11442 from appwrite/fix/sdk-beta-versioning
refactor: improve SDK generation task reliability and maintainability
2026-03-05 14:17:25 +05:30
fogelito dbbc57bc37 Merge branch '1.8.x' of https://github.com/appwrite/appwrite into stop-on-error 2026-03-05 10:31:32 +02:00
fogelito d772220414 stopOnError 2026-03-05 10:28:42 +02:00
eldadfux 9d2036024a Refactor migration error handling to provide clearer connection error messages. Updated exception messages for migration sources to guide users on potential credential and network issues. 2026-03-05 08:24:32 +01:00
premtsd-codeandGitHub cf9637288c Merge pull request #11443 from appwrite/bump-migration-1.6.3
chore: bump utopia-php/migration to 1.6.3
2026-03-05 06:59:08 +00:00
Chirag AggarwalandGitHub 5510231b8e Merge branch '1.8.x' into fix/sdk-beta-versioning 2026-03-05 09:52:47 +05:30
Chirag Aggarwal 152de6c584 remove goto 2026-03-05 09:32:07 +05:30
Prem Palanisamy d1ffa5daf3 chore: bump utopia-php/migration to 1.6.3 2026-03-04 22:42:55 +00:00
Chirag Aggarwal 161c5af66f fixes 2026-03-04 19:35:30 +05:30
Chirag Aggarwal c497a68613 fixes 2026-03-04 19:33:41 +05:30
Chirag Aggarwal 50f9c67862 fixes 2026-03-04 19:10:39 +05:30
Chirag Aggarwal 198f9a64a3 refactoring 2026-03-04 19:03:21 +05:30
Matej BačoandGitHub 4574385b31 Merge pull request #11447 from appwrite/revert-10862-feat-1.8.x-new-schema
Revert "Features with schema changes"
2026-03-04 14:31:22 +01:00
Matej BačoandGitHub f8b31e7db7 Revert "Features with schema changes" 2026-03-04 14:31:05 +01:00
Matej BačoandGitHub cf16e3e2a3 Merge pull request #10862 from appwrite/feat-1.8.x-new-schema
Features with schema changes
2026-03-04 14:29:48 +01:00
Matej Bačo ff0f132984 Fix build 2026-03-04 14:20:48 +01:00
Chirag Aggarwal 300aaeb251 fix: escape shell arguments in SDK git and gh CLI commands
Replace raw string interpolation with escapeshellarg() for all
arguments passed to exec/shell_exec calls that build git commit,
gh pr create, gh api, and gh release commands. This prevents
shell injection from AI-generated changelog text or any other
dynamically constructed values.
2026-03-04 18:46:49 +05:30
Chirag Aggarwal f989ccde57 fix: add post-parse guard for beta SDK version bumps
Add a programmatic guard after parsing the AI response that rejects
major bumps or versions >= 1.0.0 for beta SDKs. When triggered, the
SDK is skipped with a warning instead of proceeding with an invalid
version.
2026-03-04 18:44:29 +05:30
Matej Bačo 63756ddf1d Merge branch 'main' into feat-1.8.x-new-schema 2026-03-04 14:13:06 +01:00
Matej Bačo 1f5fd919c9 Merge branch '1.8.x' into feat-1.8.x-new-schema 2026-03-04 14:13:01 +01:00
Luke B. SilverandGitHub 718d3377b0 Merge pull request #11445 from appwrite/fix/publisher
fix: stats publisher
2026-03-04 11:43:10 +00:00
loks0n c81029d8aa fix: stats publisher 2026-03-04 11:17:50 +00:00
Shmuel FogelandGitHub 0291f8f943 Merge pull request #11436 from appwrite/executions-collection-index
Executions collection index functionInternalId
2026-03-04 11:14:24 +02:00
fogelito da30142b4d Merge branch '1.8.x' of https://github.com/appwrite/appwrite into executions-collection-index 2026-03-04 10:29:20 +02:00
fogelito f5047afec9 Remove index 2026-03-04 10:28:56 +02:00
Chirag Aggarwal fc88d4b4ab feat: auto-generate commit messages for SDK pushes
Remove the manual commit message prompt. When AI is available and
produces a changelog, use it as the commit message. Otherwise fall
back to a descriptive message based on SDK name and version. A
manually provided --message flag still takes priority.
2026-03-04 13:19:48 +05:30
Chirag Aggarwal df604cb2ef refactor: derive supported SDKs from config instead of hardcoded array
Replace the static supportedSDKS array with a getSupportedSDKs() method
that reads SDK keys from the sdks.php config file. This eliminates the
need to maintain the list in two places.
2026-03-04 13:16:55 +05:30
Chirag Aggarwal a6d58a847d fix: handle beta SDK versioning in DiffCheck prompt
Beta SDKs (version < 1.0.0) should not be bumped to 1.0.0. The prompt
now checks the beta flag from sdk config and instructs the AI to use
minor bumps for both breaking changes and new features, and patch for
bug fixes only. Also adds a rule to wrap code identifiers in backticks
for better changelog rendering.
2026-03-04 13:15:26 +05:30
Jake BarnbyandGitHub fdd3a58d81 Merge pull request #11440 from appwrite/perf/realtime-tests 2026-03-04 14:19:32 +13:00
loks0nandClaude Sonnet 4.5 cf7710b580 perf: reduce WebSocket timeout in realtime tests from 45s to 2s
Realtime E2E tests were taking 24+ minutes due to intentional timeout waits. Many tests verify event filtering by expecting a TimeoutException, and each was waiting 45 seconds.

Changes:
- Reduce default WebSocket timeout from 45s to 2s in RealtimeBase
- Add optional timeout parameter to getWebsocket() methods
- Use longer timeouts (5-10s) for tests that legitimately wait for slow operations (function executions, test channel events)
- Use named parameters for improved readability

Performance impact:
- Before: 24:07 minutes (1,447 seconds)
- After: 1:09 minutes (69 seconds)
- Speedup: ~21x faster

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 22:47:28 +00:00
Luke B. SilverandGitHub ccaaffed48 Merge pull request #11316 from appwrite/refactor-log-emitter
refactor: log emitter
2026-03-03 22:04:44 +00:00
loks0nandClaude Sonnet 4.5 c073743989 fix: lint - order imports in general controller
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 20:11:34 +00:00
loks0nandClaude Sonnet 4.5 a0854e0591 refactor: make Bus dispatch synchronous
Remove async coroutine wrapper from event dispatch to simplify execution model and improve trace hierarchy. Listeners now execute synchronously in the caller's context, with dependency resolution inlined.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 20:06:06 +00:00
loks0nandClaude Opus 4.6 20f248a6ae refactor: consolidate SiteRequestCompleted into RequestCompleted with optional deployment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:36:48 +00:00
loks0nandClaude Opus 4.6 c171e0c3a2 refactor: add bus.event span attribute to listener invocations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:36:48 +00:00
loks0nandClaude Opus 4.6 2081c4c42c refactor: replace bandwidth queueForStatsUsage with Bus events
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:36:48 +00:00
loks0nandClaude Opus 4.6 b45ff6b646 refactor: replace queueForExecutions with Bus event bus
Introduce a generic event bus (Utopia\Bus) with typed events, listener
base class, Span instrumentation, and coroutine dispatch. Replace all
direct queueForExecutions and inline execution usage calls with
ExecutionCompleted event and dedicated listeners (Log, Usage).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:36:48 +00:00
fogelito 5999db295d Message 2026-03-03 15:44:20 +02:00
fogelito b6b44efdab Revert length 2026-03-03 13:58:00 +02:00
fogelito f4125b8859 Remove length 2026-03-03 13:35:23 +02:00
fogelito 1ccd61ece5 functionInternalId 2026-03-03 12:15:21 +02:00
HemachandarandGitHub 598c71fb11 Move VCS events APIs to Modules (#11403)
* Move VCS events APIs to Modules

* trait

* lint

* fix external flow

* fix overriden vars

* feedback
2026-03-03 00:15:09 +05:30
Chirag AggarwalandGitHub 40fc4edb25 Merge pull request #11433 from appwrite/update-sdk-diff-check
chore: improve sdk diff check
2026-03-02 23:43:50 +05:30
Chirag Aggarwal feedd0eb4a improve prompt 2026-03-02 23:30:47 +05:30
Chirag Aggarwal f41c19ed3e improve prompt 2026-03-02 23:26:48 +05:30
Chirag Aggarwal 94bd9661b3 change 2026-03-02 23:24:48 +05:30
Chirag Aggarwal e31843be4b change 2026-03-02 23:20:29 +05:30
Chirag Aggarwal e7424e70aa chore: improve sdk diff check 2026-03-02 23:14:08 +05:30
Jake BarnbyandGitHub 5dadb9a998 Merge pull request #11430 from appwrite/realtime-tablesdb-prefix 2026-03-02 23:52:02 +13:00
HemachandarandGitHub 164c372b21 Per-project permissions for installations (#11423)
* Per-project permissions for installations

* lint
2026-03-02 16:10:58 +05:30
HemachandarandGitHub cb260fa3d8 Add authorized field to VCS repository model (#11420)
* Add authorized field to VCS repository model

* use new API
2026-03-02 15:48:08 +05:30
ArnabChatterjee20k 05a2f56945 updated tests 2026-03-02 14:27:54 +05:30
ArnabChatterjee20k 82a5d138e0 refactor: enhance event handling by ensuring unique events and improving channel merging in Realtime adapter 2026-03-02 14:22:44 +05:30
ArnabChatterjee20k 3d4f37c482 refactor: improve channel handling for Realtime events and update tests for TablesDB compatibility 2026-03-02 14:12:13 +05:30
Jake BarnbyandGitHub b03ad6d523 Merge pull request #11392 from appwrite/migration-resource-enum-fix 2026-03-02 20:09:05 +13:00
Eldad A. FuxandGitHub a5fa09b4ce Merge pull request #11185 from Shivangisharma4/doc-fix-typos-contributing
doc: Fix typos in CONTRIBUTING.md
2026-02-27 20:42:57 +01:00
Prem Palanisamy 5c2b1be581 chore: update sdk-generator to 1.11.3 2026-02-27 09:04:18 +00:00
Prem Palanisamy b3b2fe3ba2 Merge remote-tracking branch 'origin/1.8.x' into migration-resource-enum-fix 2026-02-27 09:01:31 +00:00
Jake BarnbyandGitHub 9667ec2434 Merge pull request #11414 from appwrite/fix-realtime-regions 2026-02-27 08:10:01 +00:00
Jake BarnbyandGitHub 3c4c78d086 Merge branch '1.8.x' into fix-realtime-regions 2026-02-27 06:07:06 +00:00
Jake BarnbyandGitHub 98aab7b34d Merge pull request #11421 from appwrite/realtime-log-improve 2026-02-27 05:55:13 +00:00
Prem Palanisamy 81e828a200 use named arguments and elvis operator for realtime error logging 2026-02-26 15:45:46 +00:00
Prem Palanisamy 218d25ba1b improve realtime error logging with project, user, and auth context 2026-02-26 15:45:46 +00:00
Eldad A. FuxandGitHub 908a74f69d Merge pull request #11413 from appwrite/fix-users-optional-name-error
Fix users optional name error
2026-02-26 16:23:03 +01:00
Jake BarnbyandGitHub 8368a28ff5 Merge pull request #10832 from appwrite/cached-documents-list 2026-02-26 12:55:05 +00:00
Jake BarnbyandGitHub abe21d51cb Merge pull request #11418 from appwrite/copilot/sub-pr-11414-again 2026-02-26 12:54:03 +00:00
copilot-swe-agent[bot]andabnegate 339ecc418d test: remove testConnectionFailureRegionMismatch and mock PATCH route
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 12:46:47 +00:00
copilot-swe-agent[bot]andabnegate 5ff7e2af24 fix: revert regions.php, add mock PATCH route for project region in tests
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 12:31:05 +00:00
copilot-swe-agent[bot]andabnegate 9aa47c267d test: add region mismatch failure test and fra region to config
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 12:14:23 +00:00
Prem Palanisamy 12dca64b60 Merge remote-tracking branch 'origin/1.8.x' into migration-resource-enum-fix 2026-02-26 12:04:16 +00:00
copilot-swe-agent[bot]andabnegate 952f48c72a fix: use AppwriteException, fix console region, add region check test
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 11:55:15 +00:00
copilot-swe-agent[bot] 4e80f7eb9a Initial plan 2026-02-26 11:35:32 +00:00
Jake BarnbyandGitHub 9db037722c Merge pull request #11416 from appwrite/copilot/sub-pr-11414 2026-02-26 11:33:44 +00:00
eldadfux ded05ebea2 Merge remote-tracking branch 'origin/1.8.x' into fix-users-optional-name-error 2026-02-26 12:09:12 +01:00
Eldad A. FuxandGitHub a71f3555ae Merge pull request #11412 from appwrite/fix-better-error-for-functions
Fix better error for functions
2026-02-26 12:07:31 +01:00
copilot-swe-agent[bot]andabnegate 4500349fda fix: use 'default' fallback for _APP_REGION in realtime.php
Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-02-26 11:00:09 +00:00
copilot-swe-agent[bot] 611495ec28 Initial plan 2026-02-26 10:50:52 +00:00
Jake Barnby 347436d370 Add region check 2026-02-26 23:37:26 +13:00
Jake BarnbyandGitHub dd925e335b Merge pull request #11411 from appwrite/bump-pools 2026-02-26 10:35:46 +00:00
Chirag AggarwalandGitHub 0e8b5f1d04 Merge pull request #11400 from appwrite/fix-execution-timeout-status
fix: show timed-out executions as failed across API endpoints
2026-02-26 15:43:00 +05:30
Jake BarnbyandClaude Opus 4.6 8dfaf4a9a9 fix: remove unused collectionUpdatedAt from cache key
The schema hash already captures all collection changes, making the
$updatedAt field redundant (and unreliable since Utopia preserves it).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:12:38 +13:00
Jake BarnbyandClaude Opus 4.6 d5b2f97252 fix: use md5 hash of attributes/indexes instead of counts in cache key
Counts alone miss delete/re-add scenarios where the count stays the same
but the schema is different. MD5 of the full attribute and index arrays
captures any schema change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:07:24 +13:00
eldadfux 47a81d165e Fix for user name to accept null and cast as string 2026-02-26 10:30:41 +01:00
eldadfux d7c8b9d43a Better error message when a function fail instead of general_unknown 2026-02-26 10:24:46 +01:00
ArnabChatterjee20k 7562946434 bump pools 2026-02-26 14:29:31 +05:30
Jake BarnbyandClaude Opus 4.6 4d19a5e855 fix: include attribute and index counts in document list cache key
The collection $updatedAt is preserved by Utopia Database's updateDocument()
when the value is already set, so it doesn't change when attributes/indexes
are created. Include attribute and index counts in the cache key to ensure
schema changes properly invalidate the document list cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:10:57 +13:00
Jake BarnbyandClaude Opus 4.6 1c85789a6e fix: scope cache tests to setup document IDs to avoid count mismatch
The testListDocumentsWithCache and testListDocumentsCacheBustedByAttributeChange
tests were failing because testCreateDocument (which runs earlier) adds 3 more
documents to the same movies collection, resulting in 6 docs instead of expected 3.
Additionally, the cache bust test was getting a cache 'hit' instead of 'miss' on
its first request due to sharing the same cache key as the previous test.

Fix by filtering all cache test queries to the 3 known setup document IDs, and
using distinct select/order queries in each test to avoid cache key collisions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:34:46 +13:00
HemachandarandGitHub a76a42d2dc Change validation order in delete memberships API (#11410) 2026-02-26 12:25:28 +05:30
eldadfux 79d219bf50 fix cache duplication 2026-02-26 07:50:51 +01:00
eldadfux 9b2143a2a5 Fixed cache duplication 2026-02-26 07:44:35 +01:00
Jake BarnbyandClaude Opus 4.6 b174a50b2a fix: address coderabbit review comments
- Fix indentation alignment in cache hit detection condition
- Add total count assertions for cached and non-cached responses in test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:00:25 +13:00
HemachandarandGitHub 76965252d8 Move teams API to Modules (#11358)
* Move teams API to Modules

* lint

* Move team prefs & logs API to Modules (#11359)

* Move team prefs & logs API to Modules

* format

* missin desc

* Move team memberships API to Modules (#11362)

* Move team memberships API to Modules

* fix config dir

* Cloud parity

* params

* Cloud conflicts

* refactor

* prop

* refactor

* set teamId

* feedback

* feedback 2

* fix url-encoding
2026-02-26 11:18:29 +05:30
Jake Barnby 3e0ee30428 Add tests for collection bust 2026-02-26 18:31:52 +13:00
Jake Barnby 0e7c50ef71 Include collection update time in key 2026-02-26 18:26:30 +13:00
Jake Barnby b8ee9fbe2c Update lock 2026-02-26 17:11:51 +13:00
Jake Barnby d49dea9790 Merge remote-tracking branch 'origin/1.8.x' into cached-documents-list
# Conflicts:
#	composer.lock
#	tests/e2e/Services/Databases/DatabasesBase.php
2026-02-26 17:02:33 +13:00
Chirag Aggarwal 8891890601 fix: show timed-out executions as failed across API endpoints
Executions that time out can remain stuck in waiting or processing status
in the database. This mirrors the frontend workaround from console#2788
across the relevant API endpoints for both functions and sites.

Changes:
- GET execution/log: override status to failed in response if elapsed time
  since creation exceeds the resource timeout
- LIST executions/logs: same in-response override; when caller filters by
  failed, expands DB query with OR to also fetch waiting/processing entries
  created before the timeout threshold so they appear in results; skips
  in-response override when caller explicitly requests a non-failed status
  to avoid contradicting the filter
- DELETE execution: allows deletion of timed-out executions that are still
  stored as waiting/processing by treating them as failed for the status guard

All changes are in-memory only — the database records are not modified.
Includes a note to remove once a proper DB-level fix is applied.
2026-02-26 09:22:33 +05:30
Jake BarnbyandGitHub 0272a7f337 Merge pull request #11404 from appwrite/realtime-channels-tablesdb-fix 2026-02-26 03:43:50 +00:00
Jake BarnbyandGitHub 52d5ae50d9 Merge pull request #11405 from appwrite/patch-text-types-return 2026-02-26 03:33:52 +00:00
premtsd-codeandGitHub db85004b12 Merge branch '1.8.x' into migration-resource-enum-fix 2026-02-25 23:16:52 +00:00
eldadfux b7e4f78c56 fix 2026-02-26 00:00:10 +01:00
eldadfux 159da8ba31 Fix 500 errors where we don't report duplication properly 2026-02-25 23:58:22 +01:00
HemachandarandGitHub cfddef3706 Upgrade utopia-php/vcs (#11401)
* Upgrade `utopia-php/vcs`

* fix

* bump
2026-02-25 19:54:04 +05:30
ArnabChatterjee20k ca8d613614 updated test 2026-02-25 19:46:09 +05:30
ArnabChatterjee20k 8c579cf88d updated string filtering in the filters 2026-02-25 19:29:03 +05:30
ArnabChatterjee20k a57d970840 Add support for tablesdb in event generation and realtime channels 2026-02-25 18:58:33 +05:30
Prem Palanisamy 88ce622e4a chore: update utopia-php/migration to 1.6.2 2026-02-25 12:06:12 +00:00
premtsd-codeandGitHub c041e57b8f Merge branch '1.8.x' into migration-resource-enum-fix 2026-02-25 11:57:48 +00:00
Atharva DeosthaleandGitHub 5d6e43e01b Merge pull request #11371 from appwrite/add-cursor-plugin 2026-02-25 17:11:20 +05:30
Prem Palanisamy 4a0f00f7db chore: update utopia-migration to 134efdc 2026-02-25 11:35:07 +00:00
premtsd-codeandGitHub de21cab22a Merge branch '1.8.x' into migration-resource-enum-fix 2026-02-25 10:28:28 +00:00
Prem Palanisamy f8929e1f2e fix: use utopia-migration dev branch with deployment download fixes 2026-02-25 10:25:05 +00:00
Prem Palanisamy 754ddbef3c fix: remove deployment from MigrationReport model 2026-02-25 06:47:08 +00:00
Prem Palanisamy ed93b77faa fix: remove strict WhiteList for Appwrite migration endpoints 2026-02-25 06:44:42 +00:00
Prem Palanisamy 5a6cfdbe4c fix: revert composer to 1.8.x and remove child resource counts from MigrationReport 2026-02-25 06:42:46 +00:00
Prem Palanisamy 6b90d00cc2 fix: update utopia-migration lock to include strtolower removal 2026-02-25 00:24:16 +00:00
Prem Palanisamy 3ba5e12f89 fix: add deployment and environmentVariable to MigrationReport model 2026-02-24 23:19:21 +00:00
Prem Palanisamy dc441c3973 fix: enable strict mode for Appwrite migration resource WhiteList 2026-02-24 22:44:57 +00:00
Prem Palanisamy 9745df4d37 fix: update utopia-migration to camelCase resource types 2026-02-24 22:30:32 +00:00
Prem Palanisamy 715adf62ca fix: update utopia-migration to snake_case resource types 2026-02-24 15:54:42 +00:00
Prem Palanisamy 90ab70dfc6 fix: use dev branch of utopia-php/migration for site camelCase fix 2026-02-24 15:54:42 +00:00
Prem Palanisamy b0c5185ebd feat: add site migration 2026-02-24 15:54:42 +00:00
Prem Palanisamy d58873ef14 added site migration E2E test 2026-02-24 15:54:42 +00:00
Prem Palanisamy 105c885c3f added site migration 2026-02-24 15:54:42 +00:00
Prem Palanisamy ad625029e8 feat: add migration resource enums to SDK specification 2026-02-24 15:54:42 +00:00
Jake Barnby aeaa184aec Use auth roles 2026-02-24 22:12:20 +13:00
Jake BarnbyandGitHub 9dd707cd1b Merge branch '1.8.x' into cached-documents-list 2026-02-24 09:04:32 +00:00
10e2edac90 Update CONTRIBUTING.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-24 14:03:11 +05:30
Shivangisharma4 340081a67d doc: Fix typos in CONTRIBUTING.md 2026-01-24 13:58:20 +05:30
Jake BarnbyandGitHub 9f0ca2bc53 Update src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php 2026-01-20 20:42:36 +13:00
Jake BarnbyandGitHub 605cf0c985 Update src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php 2026-01-20 18:21:14 +13:00
shimon 7e1838dda6 typo 2026-01-19 14:02:14 +02:00
shimon 2016e9bc2f Update XList parameters: remove 'cache' and adjust 'ttl' default value to 0, reflecting changes in caching logic across tests. 2026-01-19 13:14:26 +02:00
shimon b3d1410ca9 Refactor XList parameters: rename 'total' to 'includeTotal' for clarity and update related logic in action method to maintain functionality. 2026-01-18 14:27:57 +02:00
shimon a2d56a47cd merge with 1.8x 2026-01-18 11:13:45 +02:00
shimon 316bbfe43d Merge branch '1.8.x', commit '3b5351afb6473e559c945846f0bf7e928b636d83' of github.com:appwrite/appwrite into cached-documents-list
# Conflicts:
#	composer.lock
#	src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
2026-01-18 11:13:08 +02:00
Shimon NewmanandGitHub 3b5351afb6 Merge branch '1.8.x' into cached-documents-list 2026-01-06 13:03:18 +02:00
shimon ecc4052bdc Clarify TTL parameter description in XList class for cached responses to enhance documentation accuracy. 2026-01-06 12:58:58 +02:00
shimon fc84012977 Inject user dependency into XList class for enhanced functionality and improved caching accuracy. 2026-01-06 12:56:18 +02:00
shimon c882afbd2c Enhance XList class by injecting user dependency and updating cache key format to include user ID for improved caching accuracy. 2026-01-06 12:52:36 +02:00
Matej Bačo 9c3b78e18f PR review fixes 2026-01-05 13:55:12 +01:00
Matej Bačo 4365e1fee5 FIx default team labels 2026-01-05 11:33:45 +01:00
Matej Bačo d19e7c169e Merge branch '1.8.x' into feat-1.8.x-new-schema 2026-01-05 10:32:18 +01:00
Matej Bačo 226bb6b830 AI core review changes 2026-01-05 10:32:03 +01:00
Matej Bačo da5b68490c Add team labels schema 2026-01-05 10:31:58 +01:00
Matej Bačo 4dc5df3923 Fix test lockfile 2026-01-05 09:27:38 +01:00
Matej Bačo bc7bdf040f Fix vuln in test file 2026-01-02 18:42:33 +01:00
Matej Bačo f8968f69f0 Upgrade test for osv vulns 2026-01-02 18:25:30 +01:00
Matej BačoandGitHub 030e89ac7d Merge branch '1.8.x' into feat-1.8.x-new-schema 2026-01-02 18:08:37 +01:00
Matej BačoandGitHub 37330522a4 Merge pull request #11066 from appwrite/feat-specs-dual-reading
Feat: Add dual read for specs
2026-01-02 18:08:14 +01:00
Matej Bačo 807bb0039a Fix formatting 2026-01-02 16:34:20 +01:00
Matej Bačo c4c260f3cf Add dual read for specs 2026-01-02 16:34:05 +01:00
Matej Bačo 87d6c73b7b Fix failing tests 2026-01-02 12:58:53 +01:00
Matej Bačo a71875eedf Merge branch '1.8.x' into feat-1.8.x-new-schema 2026-01-02 12:47:44 +01:00
Matej Bačo 51e03353dd Merge branch '1.8.x' into feat-1.8.x-new-schema 2025-12-31 15:13:35 +01:00
Matej BačoandGitHub 175b0d92f2 Merge pull request #10959 from appwrite/feat-auto-delete-depoyments
Feat: Auto-delete deployments
2025-12-16 13:30:44 +01:00
Matej Bačo c88a77a31c AI suggestion fixes 2025-12-16 12:53:06 +01:00
Matej Bačo a3a5e05b5c bug fix 2025-12-15 20:13:45 +01:00
Matej Bačo bd2db5e249 AI review fixes 2025-12-15 19:45:57 +01:00
Matej Bačo 6cbc79026f Finish site tests for auto deletion 2025-12-15 18:55:34 +01:00
Matej Bačo 0ec755911e Finish maintenance test 2025-12-15 18:44:27 +01:00
Matej Bačo be626ad0fc Add deployment retention to sites and functions 2025-12-15 17:33:10 +01:00
shimon 305697a5d3 Update composer dependencies and refactor cache handling in XList class for improved performance 2025-12-09 11:29:09 +02:00
shimon d954681132 matcing documents xlist params order 2025-12-08 15:41:13 +02:00
shimon 872345366a linter 2025-12-08 13:43:05 +02:00
shimon 5b1aec0f0b Enhance XList functionality by adding cache and TTL parameters for improved query performance and response management. 2025-12-08 13:34:09 +02:00
shimon 98d063132c composer.lock 2025-12-08 09:57:49 +02:00
shimon fcb3699e99 Merge branch 'refactor-auth-single-instance' of github.com:appwrite/appwrite into cached-documents-list
# Conflicts:
#	composer.json
#	composer.lock
2025-12-08 09:56:48 +02:00
shimon 39658a7ee1 fixing comments 2025-12-08 09:55:15 +02:00
Matej BačoandGitHub 914723c53a Merge branch '1.8.x' into feat-1.8.x-new-schema 2025-11-24 19:10:44 +01:00
Matej BačoandGitHub db965f8144 Merge pull request #10842 from appwrite/feat-custom-start-commands
Feat: Custom start commands
2025-11-24 16:01:01 +01:00
Matej Bačo d99a52741d Merge branch 'feat-1.8.x-new-schema' into feat-custom-start-commands 2025-11-24 15:41:52 +01:00
Matej BačoandGitHub 96f79fac0e Merge pull request #10849 from appwrite/feat-separate-specifications
Separate specifications
2025-11-24 15:39:02 +01:00
Matej Bačo 856c1d685e Remove functions refference to start command 2025-11-24 15:19:41 +01:00
Matej Bačo 5817aea2ee Remove todo 2025-11-24 14:30:45 +01:00
shimon 6213df9fb3 Update composer.lock for sdk-generator version 1.5.8 and adjust caching TTL in XList to 30 seconds; modify related tests for consistency in cache behavior. 2025-11-23 12:46:16 +02:00
Matej Bačo 4bc624ea5c Fix response filters order 2025-11-20 18:05:32 +01:00
Matej Bačo 7444f5cf60 leftover fix 2025-11-20 17:50:15 +01:00
Matej Bačo cbb96f8b82 AI review fixes 2025-11-20 17:40:56 +01:00
Matej Bačo 63f6840d1b Finish implementation, update tests 2025-11-20 17:39:19 +01:00
Matej Bačo 62173b8f61 WIP: Separate specifications 2025-11-20 13:56:54 +01:00
Matej Bačo 7f3c0c9c03 AI review fixes 2025-11-19 16:38:47 +01:00
Matej Bačo f907f76eb5 Custom start command implementation 2025-11-19 15:27:54 +01:00
shimon 30df7a43d5 Refactor caching logic in XList to use JSON encoding for query serialization and update cache header keys to lowercase in tests 2025-11-18 20:11:02 +02:00
shimon 8fc9fdb7dc cached documents list 2025-11-18 16:37:52 +02:00
shimon 77905e241f cached documents list 2025-11-18 16:26:56 +02:00
133 changed files with 6199 additions and 3267 deletions
+30
View File
@@ -47,6 +47,36 @@ Examples:
'resourceType' => 'deployments'
```
## Performance Patterns
### Document Update Optimization
When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`.
**Correct Pattern:**
```php
// Good: Pass only changed attributes directly
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'name' => $name,
'email' => $email,
]));
```
**Incorrect Pattern:**
```php
$user->setAttribute('name', $name);
$user->setAttribute('email', $email);
// Bad: Passing full document is inefficient
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
```
**Exceptions:**
- Migration files (need full document updates by design)
- Cases already using `array_merge()` with `getArrayCopy()`
- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document)
- Complex nested relationship logic where full document state is required
## Security Considerations
### Critical Security Practices
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+4
View File
@@ -318,6 +318,10 @@ $setResource('logError', function (Registry $register) {
$setResource('executor', fn () => new Executor(), []);
$setResource('bus', function (Registry $register) use ($cli) {
return $register->get('bus')->setResolver(fn (string $name) => $cli->getResource($name));
}, ['register']);
$setResource('telemetry', fn () => new NoTelemetry(), []);
$cli
-7
View File
@@ -2190,13 +2190,6 @@ return [
'lengths' => [],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_function_internal_id'),
'type' => Database::INDEX_KEY,
'attributes' => ['resourceInternalId'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_resourceType'),
'type' => Database::INDEX_KEY,
+1 -1
View File
@@ -26,7 +26,7 @@ $console = [
'hostname' => 'localhost',
], // Current host is added on app init
],
'region' => 'fra',
'region' => System::getEnv('_APP_REGION', 'default'),
'legalName' => '',
'legalCountry' => '',
'legalState' => '',
+13 -3
View File
@@ -27,7 +27,7 @@ return [
Exception::GENERAL_RESOURCE_BLOCKED => [
'name' => Exception::GENERAL_RESOURCE_BLOCKED,
'description' => 'Access to this resource is blocked.',
'code' => 401,
'code' => 403,
],
Exception::GENERAL_UNKNOWN_ORIGIN => [
'name' => Exception::GENERAL_UNKNOWN_ORIGIN,
@@ -168,8 +168,8 @@ return [
],
Exception::USER_BLOCKED => [
'name' => Exception::USER_BLOCKED,
'description' => 'The current user has been blocked. You can unblock the user by making a request to the User API\'s "Update User Status" endpoint or in the Appwrite Console\'s Auth section.',
'code' => 401,
'description' => 'The current user has been blocked.',
'code' => 403,
],
Exception::USER_INVALID_TOKEN => [
'name' => Exception::USER_INVALID_TOKEN,
@@ -630,6 +630,11 @@ return [
'description' => 'Site with the requested ID could not be found.',
'code' => 404,
],
Exception::SITE_ALREADY_EXISTS => [
'name' => Exception::SITE_ALREADY_EXISTS,
'description' => 'Site with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::SITE_TEMPLATE_NOT_FOUND => [
'name' => Exception::SITE_TEMPLATE_NOT_FOUND,
'description' => 'Site Template with the requested ID could not be found.',
@@ -1291,6 +1296,11 @@ return [
'description' => 'Message with the requested ID could not be found.',
'code' => 404,
],
Exception::MESSAGE_ALREADY_EXISTS => [
'name' => Exception::MESSAGE_ALREADY_EXISTS,
'description' => 'Message with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::MESSAGE_MISSING_TARGET => [
'name' => Exception::MESSAGE_MISSING_TARGET,
'description' => 'Message with the requested ID has no recipients (topics or users or targets).',
+1 -1
View File
@@ -231,7 +231,7 @@ return [
'url' => 'https://github.com/appwrite/sdk-for-cli',
'package' => 'https://www.npmjs.com/package/appwrite-cli',
'enabled' => true,
'beta' => true,
'beta' => false,
'dev' => false,
'hidden' => false,
'family' => APP_SDK_PLATFORM_CONSOLE,
+2 -2
View File
@@ -160,7 +160,7 @@ return [
'name' => 'Teams',
'subtitle' => 'The Teams service allows you to group users of your project and to enable them to share read and write access to your project resources',
'description' => '/docs/services/teams.md',
'controller' => 'api/teams.php',
'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/client/teams',
@@ -188,7 +188,7 @@ return [
'name' => 'VCS',
'subtitle' => 'The VCS service allows you to interact with providers like GitHub, GitLab etc.',
'description' => '',
'controller' => 'api/vcs.php',
'controller' => '', // Uses modules
'sdk' => false,
'docs' => false,
'docsUrl' => '',
+37 -15
View File
@@ -288,7 +288,10 @@ $createSession = function (string $userId, string $secret, Request $request, Res
}
try {
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document([
'emailVerification' => $user->getAttribute('emailVerification'),
'phoneVerification' => $user->getAttribute('phoneVerification'),
]));
} catch (\Throwable $th) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
}
@@ -1032,7 +1035,11 @@ Http::post('/v1/account/sessions/email')
->setAttribute('password', $proofForPasswordUpdated->hash($password))
->setAttribute('hash', $proofForPasswordUpdated->getHash()->getName())
->setAttribute('hashOptions', $proofForPasswordUpdated->getHash()->getOptions());
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document([
'password' => $user->getAttribute('password'),
'hash' => $user->getAttribute('hash'),
'hashOptions' => $user->getAttribute('hashOptions'),
]));
}
$dbForProject->purgeCachedDocument('users', $user->getId());
@@ -1822,7 +1829,11 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->setAttribute('providerAccessToken', $accessToken)
->setAttribute('providerRefreshToken', $refreshToken)
->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry));
$dbForProject->updateDocument('identities', $identity->getId(), $identity);
$dbForProject->updateDocument('identities', $identity->getId(), new Document([
'providerAccessToken' => $identity->getAttribute('providerAccessToken'),
'providerRefreshToken' => $identity->getAttribute('providerRefreshToken'),
'providerAccessTokenExpiry' => $identity->getAttribute('providerAccessTokenExpiry'),
]));
}
if (empty($user->getAttribute('email'))) {
@@ -1960,7 +1971,10 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->setAttribute('sessionId', $session->getId())
->setAttribute('sessionInternalId', $session->getSequence());
$dbForProject->updateDocument('targets', $target->getId(), $target);
$dbForProject->updateDocument('targets', $target->getId(), new Document([
'sessionId' => $target->getAttribute('sessionId'),
'sessionInternalId' => $target->getAttribute('sessionInternalId'),
]));
}
}
@@ -3145,7 +3159,9 @@ Http::patch('/v1/account/name')
$user->setAttribute('name', $name);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'name' => $user->getAttribute('name'),
]));
$queueForEvents->setParam('userId', $user->getId());
@@ -3798,13 +3814,15 @@ Http::put('/v1/account/recovery')
$hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]);
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)
->setAttribute('passwordUpdate', DateTime::now())
->setAttribute('hash', $proofForPassword->getHash()->getName())
->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions())
->setAttribute('emailVerification', true));
$profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(
[
'password' => $newPassword,
'passwordHistory' => $history,
'passwordUpdate' => DateTime::now(),
'hash' => $proofForPassword->getHash()->getName(),
'hashOptions' => $proofForPassword->getHash()->getOptions(),
'emailVerification' => true]
));
$user->setAttributes($profile->getArrayCopy());
@@ -4126,7 +4144,7 @@ Http::put('/v1/account/verifications/email')
$authorization->addRole(Role::user($profile->getId())->toString());
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('emailVerification', true));
$profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(['emailVerification' => true]));
$user->setAttributes($profile->getArrayCopy());
@@ -4342,7 +4360,7 @@ Http::put('/v1/account/verifications/phone')
$authorization->addRole(Role::user($profile->getId())->toString());
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('phoneVerification', true));
$profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(['phoneVerification' => true]));
$user->setAttributes($profile->getArrayCopy());
@@ -4500,7 +4518,11 @@ Http::put('/v1/account/targets/:targetId/push')
$target->setAttribute('name', "{$device['deviceBrand']} {$device['deviceModel']}");
$target = $dbForProject->updateDocument('targets', $target->getId(), $target);
$target = $dbForProject->updateDocument('targets', $target->getId(), new Document([
'identifier' => $target->getAttribute('identifier'),
'expired' => $target->getAttribute('expired'),
'name' => $target->getAttribute('name'),
]));
$dbForProject->purgeCachedDocument('users', $user->getId());
+21 -6
View File
@@ -3251,7 +3251,7 @@ Http::post('/v1/messaging/messages/email')
}
}
$message = $dbForProject->createDocument('messages', new Document([
$message = new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_EMAIL,
'topics' => $topics,
@@ -3267,7 +3267,12 @@ Http::post('/v1/messaging/messages/email')
'attachments' => $attachments,
],
'status' => $status,
]));
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3400,7 +3405,7 @@ Http::post('/v1/messaging/messages/sms')
}
}
$message = $dbForProject->createDocument('messages', new Document([
$message = new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_SMS,
'topics' => $topics,
@@ -3410,7 +3415,12 @@ Http::post('/v1/messaging/messages/sms')
'content' => $content,
],
'status' => $status,
]));
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3620,7 +3630,7 @@ Http::post('/v1/messaging/messages/push')
$pushData['priority'] = $priority;
}
$message = $dbForProject->createDocument('messages', new Document([
$message = new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_PUSH,
'topics' => $topics,
@@ -3629,7 +3639,12 @@ Http::post('/v1/messaging/messages/push')
'scheduledAt' => $scheduledAt,
'data' => $pushData,
'status' => $status,
]));
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
switch ($status) {
case MessageStatus::PROCESSING:
+20 -49
View File
@@ -717,22 +717,14 @@ Http::get('/v1/migrations/appwrite/report')
->inject('project')
->inject('user')
->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response) {
$appwrite = new Appwrite($projectID, $endpoint, $key);
try {
$appwrite = new Appwrite($projectID, $endpoint, $key);
$report = $appwrite->report($resources);
} catch (\Throwable $e) {
switch ($e->getCode()) {
case 401:
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
case 429:
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
case 500:
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
}
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
@@ -771,21 +763,14 @@ Http::get('/v1/migrations/firebase/report')
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
}
$firebase = new Firebase($serviceAccount);
try {
$firebase = new Firebase($serviceAccount);
$report = $firebase->report($resources);
} catch (\Throwable $e) {
switch ($e->getCode()) {
case 401:
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
case 429:
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
case 500:
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
}
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
@@ -820,21 +805,14 @@ Http::get('/v1/migrations/supabase/report')
->inject('response')
->inject('dbForProject')
->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) {
$supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port);
try {
$supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port);
$report = $supabase->report($resources);
} catch (\Throwable $e) {
switch ($e->getCode()) {
case 401:
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
case 429:
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
case 500:
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
}
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
@@ -869,21 +847,14 @@ Http::get('/v1/migrations/nhost/report')
->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
->inject('response')
->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) {
$nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port);
try {
$nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port);
$report = $nhost->report($resources);
} catch (\Throwable $e) {
switch ($e->getCode()) {
case 401:
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
case 429:
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
case 500:
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
}
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
File diff suppressed because it is too large Load Diff
+54 -25
View File
@@ -73,8 +73,9 @@ use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
/** TODO: Remove function when we move to using utopia/platform */
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
{
$name = $name ?? '';
$plaintextPassword = $password;
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
@@ -255,7 +256,7 @@ Http::post('/v1/users')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$plaintext = new Plaintext();
$user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
@@ -291,7 +292,7 @@ Http::post('/v1/users/bcrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$bcrypt = new Bcrypt();
$bcrypt->setCost(8); // Default cost
@@ -329,7 +330,7 @@ Http::post('/v1/users/md5')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$md5 = new MD5();
$user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -366,7 +367,7 @@ Http::post('/v1/users/argon2')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$argon2 = new Argon2();
$user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -404,7 +405,7 @@ Http::post('/v1/users/sha')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$sha = new Sha();
if (!empty($passwordVersion)) {
$sha->setVersion($passwordVersion);
@@ -444,7 +445,7 @@ Http::post('/v1/users/phpass')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$phpass = new PHPass();
$user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -486,7 +487,7 @@ Http::post('/v1/users/scrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$scrypt = new Scrypt();
$scrypt
->setSalt($passwordSalt)
@@ -532,7 +533,7 @@ Http::post('/v1/users/scrypt-modified')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$scryptModified = new ScryptModified();
$scryptModified
->setSalt($passwordSalt)
@@ -1160,7 +1161,7 @@ Http::patch('/v1/users/:userId/status')
throw new Exception(Exception::USER_NOT_FOUND);
}
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('status', (bool) $status));
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['status' => (bool) $status]));
$queueForEvents
->setParam('userId', $user->getId());
@@ -1203,7 +1204,7 @@ Http::put('/v1/users/:userId/labels')
$user->setAttribute('labels', (array) \array_values(\array_unique($labels)));
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['labels' => $user->getAttribute('labels')]));
$queueForEvents
->setParam('userId', $user->getId());
@@ -1244,7 +1245,7 @@ Http::patch('/v1/users/:userId/verification/phone')
throw new Exception(Exception::USER_NOT_FOUND);
}
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('phoneVerification', $phoneVerification));
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['phoneVerification' => $phoneVerification]));
$queueForEvents
->setParam('userId', $user->getId());
@@ -1288,7 +1289,7 @@ Http::patch('/v1/users/:userId/name')
$user->setAttribute('name', $name);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['name' => $user->getAttribute('name')]));
$queueForEvents->setParam('userId', $user->getId());
@@ -1343,7 +1344,10 @@ Http::patch('/v1/users/:userId/password')
->setAttribute('password', '')
->setAttribute('passwordUpdate', DateTime::now());
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'password' => $user->getAttribute('password'),
'passwordUpdate' => $user->getAttribute('passwordUpdate'),
]));
$queueForEvents->setParam('userId', $user->getId());
$response->dynamic($user, Response::MODEL_USER);
}
@@ -1376,7 +1380,13 @@ Http::patch('/v1/users/:userId/password')
->setAttribute('hash', $hasher->getName())
->setAttribute('hashOptions', $hasher->getOptions());
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'password' => $user->getAttribute('password'),
'passwordHistory' => $user->getAttribute('passwordHistory'),
'passwordUpdate' => $user->getAttribute('passwordUpdate'),
'hash' => $user->getAttribute('hash'),
'hashOptions' => $user->getAttribute('hashOptions'),
]));
$sessions = $user->getAttribute('sessions', []);
$invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false;
@@ -1468,7 +1478,15 @@ Http::patch('/v1/users/:userId/email')
;
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'email' => $user->getAttribute('email'),
'emailVerification' => $user->getAttribute('emailVerification'),
'emailCanonical' => $user->getAttribute('emailCanonical'),
'emailIsCanonical' => $user->getAttribute('emailIsCanonical'),
'emailIsCorporate' => $user->getAttribute('emailIsCorporate'),
'emailIsDisposable' => $user->getAttribute('emailIsDisposable'),
'emailIsFree' => $user->getAttribute('emailIsFree'),
]));
/**
* @var Document $oldTarget
*/
@@ -1476,7 +1494,8 @@ Http::patch('/v1/users/:userId/email')
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
if (\strlen($email) !== 0) {
$dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $email));
$dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $email]));
$oldTarget->setAttribute('identifier', $email);
} else {
$dbForProject->deleteDocument('targets', $oldTarget->getId());
}
@@ -1557,7 +1576,10 @@ Http::patch('/v1/users/:userId/phone')
}
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'phone' => $user->getAttribute('phone'),
'phoneVerification' => $user->getAttribute('phoneVerification'),
]));
/**
* @var Document $oldTarget
*/
@@ -1565,7 +1587,8 @@ Http::patch('/v1/users/:userId/phone')
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
if (\strlen($number) !== 0) {
$dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $number));
$dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number]));
$oldTarget->setAttribute('identifier', $number);
} else {
$dbForProject->deleteDocument('targets', $oldTarget->getId());
}
@@ -1629,7 +1652,7 @@ Http::patch('/v1/users/:userId/verification')
throw new Exception(Exception::USER_NOT_FOUND);
}
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', $emailVerification));
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['emailVerification' => $emailVerification]));
$queueForEvents->setParam('userId', $user->getId());
@@ -1667,7 +1690,7 @@ Http::patch('/v1/users/:userId/prefs')
throw new Exception(Exception::USER_NOT_FOUND);
}
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs));
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['prefs' => $prefs]));
$queueForEvents
->setParam('userId', $user->getId());
@@ -1767,7 +1790,13 @@ Http::patch('/v1/users/:userId/targets/:targetId')
$target->setAttribute('name', $name);
}
$target = $dbForProject->updateDocument('targets', $target->getId(), $target);
$target = $dbForProject->updateDocument('targets', $target->getId(), new Document([
'identifier' => $target->getAttribute('identifier'),
'expired' => $target->getAttribute('expired'),
'providerId' => $target->getAttribute('providerId'),
'providerInternalId' => $target->getAttribute('providerInternalId'),
'name' => $target->getAttribute('name'),
]));
$dbForProject->purgeCachedDocument('users', $user->getId());
$queueForEvents
@@ -1835,7 +1864,7 @@ Http::patch('/v1/users/:userId/mfa')
$user->setAttribute('mfa', $mfa);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['mfa' => $user->getAttribute('mfa')]));
$queueForEvents->setParam('userId', $user->getId());
@@ -2023,7 +2052,7 @@ Http::patch('/v1/users/:userId/mfa/recovery-codes')
$mfaRecoveryCodes = Type::generateBackupCodes();
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes]));
$queueForEvents->setParam('userId', $user->getId());
@@ -2095,7 +2124,7 @@ Http::put('/v1/users/:userId/mfa/recovery-codes')
$mfaRecoveryCodes = Type::generateBackupCodes();
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes]));
$queueForEvents->setParam('userId', $user->getId());
-705
View File
@@ -1,705 +0,0 @@
<?php
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Appwrite\Vcs\Comment;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Http\Http;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
$createGitDeployments = function (GitHub $github, string $providerInstallationId, array $repositories, string $providerBranch, string $providerBranchUrl, string $providerRepositoryName, string $providerRepositoryUrl, string $providerRepositoryOwner, string $providerCommitHash, string $providerCommitAuthor, string $providerCommitAuthorUrl, string $providerCommitMessage, string $providerCommitUrl, string $providerPullRequestId, bool $external, Database $dbForPlatform, Authorization $authorization, Build $queueForBuilds, callable $getProjectDB, Request $request, array $platform) {
$errors = [];
foreach ($repositories as $repository) {
try {
$resourceType = $repository->getAttribute('resourceType');
if ($resourceType !== "function" && $resourceType !== "site") {
continue;
}
$projectId = $repository->getAttribute('projectId');
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project');
}
$dbForProject = $getProjectDB($project);
$resourceCollection = $resourceType === "function" ? 'functions' : 'sites';
$resourceId = $repository->getAttribute('resourceId');
$resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId));
$resourceInternalId = $resource->getSequence();
$deploymentId = ID::unique();
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
$installationId = $repository->getAttribute('installationId');
$installationInternalId = $repository->getAttribute('installationInternalId');
$productionBranch = $resource->getAttribute('providerBranch');
$activate = false;
if ($providerBranch == $productionBranch && $external === false) {
$activate = true;
}
$owner = $github->getOwnerName($providerInstallationId) ?? '';
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$isAuthorized = !$external;
if (!$isAuthorized && !empty($providerPullRequestId)) {
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
$isAuthorized = true;
}
}
$commentStatus = $isAuthorized ? 'waiting' : 'failed';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$hostname = $platform['consoleHostname'] ?? '';
$authorizeUrl = $protocol . '://' . $hostname . "/console/git/authorize-contributor?projectId={$projectId}&installationId={$installationId}&repositoryId={$repositoryId}&providerPullRequestId={$providerPullRequestId}";
$action = $isAuthorized ? ['type' => 'logs'] : ['type' => 'authorize', 'url' => $authorizeUrl];
$latestCommentId = '';
if (!empty($providerPullRequestId) && $resource->getAttribute('providerSilentMode', false) === false) {
$latestComment = $authorization->skip(fn () => $dbForPlatform->findOne('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerPullRequestId', [$providerPullRequestId]),
Query::orderDesc('$createdAt'),
]));
if (!$latestComment->isEmpty()) {
$latestCommentId = $latestComment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
} else {
$comment = new Comment($platform);
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment()));
if (!empty($latestCommentId)) {
$teamId = $project->getAttribute('teamId', '');
$latestComment = $authorization->skip(fn () => $dbForPlatform->createDocument('vcsComments', new Document([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
Permission::update(Role::team(ID::custom($teamId), 'owner')),
Permission::update(Role::team(ID::custom($teamId), 'developer')),
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
],
'installationInternalId' => $installationInternalId,
'installationId' => $installationId,
'projectInternalId' => $project->getSequence(),
'projectId' => $project->getId(),
'providerRepositoryId' => $providerRepositoryId,
'providerBranch' => $providerBranch,
'providerPullRequestId' => $providerPullRequestId,
'providerCommentId' => $latestCommentId
])));
}
}
} elseif (!empty($providerBranch)) {
$latestComments = $authorization->skip(fn () => $dbForPlatform->find('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerBranch', [$providerBranch]),
Query::orderDesc('$createdAt'),
]));
foreach ($latestComments as $comment) {
$latestCommentId = $comment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
}
if (!$isAuthorized) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$name = "{$resourceName} ({$projectName})";
$message = 'Authorization required for external contributor.';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'failure', $message, $authorizeUrl, $name);
continue;
}
if ($external) {
$pullRequestResponse = $github->getPullRequest($owner, $repositoryName, $providerPullRequestId);
$providerRepositoryName = $pullRequestResponse['head']['repo']['owner']['login'];
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['name'];
}
$commands = [];
if (!empty($resource->getAttribute('installCommand', ''))) {
$commands[] = $resource->getAttribute('installCommand', '');
}
if (!empty($resource->getAttribute('buildCommand', ''))) {
$commands[] = $resource->getAttribute('buildCommand', '');
}
if (!empty($resource->getAttribute('commands', ''))) {
$commands[] = $resource->getAttribute('commands', '');
}
$deployment = $authorization->skip(fn () => $dbForProject->createDocument('deployments', new Document([
'$id' => $deploymentId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
'resourceType' => $resourceCollection,
'entrypoint' => $resource->getAttribute('entrypoint', ''),
'buildCommands' => \implode(' && ', $commands),
'startCommand' => $resource->getAttribute('startCommand', ''),
'buildOutput' => $resource->getAttribute('outputDirectory', ''),
'adapter' => $resource->getAttribute('adapter', ''),
'fallbackFile' => $resource->getAttribute('fallbackFile', ''),
'type' => 'vcs',
'installationId' => $installationId,
'installationInternalId' => $installationInternalId,
'providerRepositoryId' => $providerRepositoryId,
'repositoryId' => $repositoryId,
'repositoryInternalId' => $repositoryInternalId,
'providerBranchUrl' => $providerBranchUrl,
'providerRepositoryName' => $providerRepositoryName,
'providerRepositoryOwner' => $providerRepositoryOwner,
'providerRepositoryUrl' => $providerRepositoryUrl,
'providerCommitHash' => $providerCommitHash,
'providerCommitAuthorUrl' => $providerCommitAuthorUrl,
'providerCommitAuthor' => $providerCommitAuthor,
'providerCommitMessage' => mb_strimwidth($providerCommitMessage, 0, 255, '...'),
'providerCommitUrl' => $providerCommitUrl,
'providerCommentId' => \strval($latestCommentId),
'providerBranch' => $providerBranch,
'activate' => $activate,
])));
$resource = $resource
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$authorization->skip(fn () => $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), $resource));
if ($resource->getCollection() === 'sites') {
$projectId = $project->getId();
// Deployment preview
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$previewRuleId = $ruleId;
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
// VCS branch preview
if (!empty($providerBranch)) {
$domain = (new BranchDomainFilter())->apply([
'branch' => $providerBranch,
'resourceId' => $resource->getId(),
'projectId' => $project->getId(),
'sitesDomain' => $sitesDomain,
]);
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
// VCS commit preview
if (!empty($providerCommitHash)) {
$domain = "commit-" . substr($providerCommitHash, 0, 16) . ".{$sitesDomain}";
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
}
if ($resource->getCollection() === 'sites' && !empty($latestCommentId) && !empty($previewRuleId)) {
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $previewRuleId));
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '';
if (!empty($previewUrl)) {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $previewUrl);
$github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment());
}
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
if (!empty($providerCommitHash) && $resource->getAttribute('providerSilentMode', false) === false) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$region = $project->getAttribute('region', 'default');
$name = "{$resourceName} ({$projectName})";
$message = 'Starting...';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$providerTargetUrl = $protocol . '://' . $hostname . "/console/project-$region-$projectId/$resourceCollection/$resourceType-$resourceId";
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $providerTargetUrl, $name);
}
$queueForBuilds
->setType(BUILD_TYPE_DEPLOYMENT)
->setResource($resource)
->setDeployment($deployment)
->setProject($project); // set the project because it won't be set for git deployments
$queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
//TODO: Add event?
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
$queueForBuilds->reset(); // prevent shutdown hook from triggering again
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
};
Http::post('/v1/vcs/github/events')
->desc('Create event')
->groups(['api', 'vcs'])
->label('scope', 'public')
->inject('gitHub')
->inject('request')
->inject('response')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->action(
function (GitHub $github, Request $request, Response $response, Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) {
$payload = $request->getRawPayload();
$signatureRemote = $request->getHeader('x-hub-signature-256', '');
$signatureLocal = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', '');
$valid = empty($signatureRemote) ? true : $github->validateWebhookEvent($payload, $signatureRemote, $signatureLocal);
if (!$valid) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, "Invalid webhook payload signature. Please make sure the webhook secret has same value in your GitHub app and in the _APP_VCS_GITHUB_WEBHOOK_SECRET environment variable");
}
$event = $request->getHeader('x-github-event', '');
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$parsedPayload = $github->getEvent($event, $payload);
if ($event == $github::EVENT_PUSH) {
$providerBranchCreated = $parsedPayload["branchCreated"] ?? false;
$providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false;
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$providerCommitAuthorName = $parsedPayload["headCommitAuthorName"] ?? '';
$providerCommitAuthorEmail = $parsedPayload["headCommitAuthorEmail"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
$providerCommitMessage = $parsedPayload["headCommitMessage"] ?? '';
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
//find resourceId from relevant resources table
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::limit(100),
]));
// create new deployment only on push (not committed by us) and not when branch is created or deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) {
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
}
} elseif ($event == $github::EVENT_INSTALLATION) {
if ($parsedPayload["action"] == "deleted") {
// TODO: Use worker for this job instead (update function/site as well)
$providerInstallationId = $parsedPayload["installationId"];
$installations = $dbForPlatform->find('installations', [
Query::equal('providerInstallationId', [$providerInstallationId]),
Query::limit(1000)
]);
foreach ($installations as $installation) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('installationInternalId', [$installation->getSequence()]),
Query::limit(1000)
]));
foreach ($repositories as $repository) {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('repositories', $repository->getId()));
}
$authorization->skip(fn () => $dbForPlatform->deleteDocument('installations', $installation->getId()));
}
}
} elseif ($event == $github::EVENT_PULL_REQUEST) {
if ($parsedPayload["action"] == "opened" || $parsedPayload["action"] == "reopened" || $parsedPayload["action"] == "synchronize") {
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$external = $parsedPayload["external"] ?? true;
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
// Ignore sync for non-external. We handle it in push webhook
if (!$external && $parsedPayload["action"] == "synchronize") {
return $response->json($parsedPayload);
}
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash);
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
} elseif ($parsedPayload["action"] == "closed") {
// Allowed external contributions cleanup
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$external = $parsedPayload["external"] ?? true;
if ($external) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
foreach ($repositories as $repository) {
$providerPullRequestIds = $repository->getAttribute('providerPullRequestIds', []);
if (\in_array($providerPullRequestId, $providerPullRequestIds)) {
$providerPullRequestIds = \array_diff($providerPullRequestIds, [$providerPullRequestId]);
$repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds);
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository));
}
}
}
}
}
$response->json($parsedPayload);
}
);
Http::patch('/v1/vcs/github/installations/:installationId/repositories/:repositoryId')
->desc('Update external deployment (authorize)')
->groups(['api', 'vcs'])
->label('scope', 'vcs.write')
->label('sdk', new Method(
namespace: 'vcs',
group: 'repositories',
name: 'updateExternalDeployments',
description: '/docs/references/vcs/update-external-deployments.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
]
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('repositoryId', '', new Text(256), 'VCS Repository Id')
->param('providerPullRequestId', '', new Text(256), 'GitHub Pull Request Id')
->inject('gitHub')
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->action(function (string $installationId, string $repositoryId, string $providerPullRequestId, GitHub $github, Request $request, Response $response, Document $project, Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
}
$repository = $authorization->skip(fn () => $dbForPlatform->findOne('repositories', [
Query::equal('$id', [$repositoryId]),
Query::equal('projectInternalId', [$project->getSequence()])
]));
if ($repository->isEmpty()) {
throw new Exception(Exception::REPOSITORY_NOT_FOUND);
}
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
throw new Exception(Exception::PROVIDER_CONTRIBUTION_CONFLICT);
}
$providerPullRequestIds = \array_unique(\array_merge($repository->getAttribute('providerPullRequestIds', []), [$providerPullRequestId]));
$repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds);
// TODO: Delete from array when PR is closed
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository));
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$providerInstallationId = $installation->getAttribute('providerInstallationId');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$repositories = [$repository];
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
$owner = $github->getOwnerName($providerInstallationId);
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$pullRequestResponse = $github->getPullRequest($owner, $repositoryName, $providerPullRequestId);
$providerBranch = \explode(':', $pullRequestResponse['head']['label'])[1] ?? '';
$providerCommitHash = $pullRequestResponse['head']['sha'] ?? '';
$providerBranchUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
$providerRepositoryName = $pullRequestResponse['head']['repo']['name'] ?? '';
$providerRepositoryUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['owner']['login'] ?? '';
$providerCommitAuthor = $pullRequestResponse['head']['user']['login'] ?? '';
$providerCommitAuthorUrl = $pullRequestResponse['head']['user']['html_url'] ?? '';
$providerCommitMessage = $pullRequestResponse['title'] ?? '';
$providerCommitUrl = $pullRequestResponse['html_url'] ?? '';
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, '', '', '', '', $providerCommitHash, '', '', '', '', $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
$response->noContent();
});
+47 -112
View File
@@ -5,11 +5,11 @@ require_once __DIR__ . '/../init.php';
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Bus\Events\RequestCompleted;
use Appwrite\Event\Certificate;
use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
use Appwrite\Event\Execution;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Network\Cors;
use Appwrite\Platform\Appwrite;
@@ -35,6 +35,7 @@ use Executor\Executor;
use MaxMind\Db\Reader;
use Swoole\Http\Request as SwooleRequest;
use Swoole\Table;
use Utopia\Bus\Bus;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Database;
@@ -62,7 +63,7 @@ Config::setParam('domainVerification', false);
Config::setParam('cookieDomain', 'localhost');
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
{
$host = $request->getHostname() ?? '';
if (!empty($previewHostname)) {
@@ -131,8 +132,9 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (!$project->isEmpty() && $project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
$project->setAttribute('accessedAt', DateTime::now());
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project));
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
'accessedAt' => DateTime::now()
])));
}
/**
@@ -706,10 +708,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
} finally {
if ($type === 'function' || $type === 'site') {
$queueForExecutions
->setExecution($execution)
->setProject($project)
->trigger();
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
spec: $spec,
resource: $resource->getArrayCopy(),
));
}
}
@@ -754,70 +758,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
->setStatusCode($execution['responseStatusCode'] ?? 200)
->send($body);
$fileSize = 0;
$file = $request->getFiles('file');
if (!empty($file)) {
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
}
if (!empty($apiKey) && !empty($apiKey->getDisabledMetrics())) {
foreach ($apiKey->getDisabledMetrics() as $key) {
$queueForStatsUsage->disableMetric($key);
}
}
$metricTypeExecutions = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS);
$metricTypeIdExecutions = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS);
$metricTypeExecutionsCompute = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE);
$metricTypeIdExecutionsCompute = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE);
$metricTypeExecutionsMbSeconds = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS);
$metricTypeIdExecutionsMBSeconds = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS);
if ($deployment->getAttribute('resourceType') === 'sites') {
$queueForStatsUsage
->disableMetric(METRIC_NETWORK_REQUESTS)
->disableMetric(METRIC_NETWORK_INBOUND)
->disableMetric(METRIC_NETWORK_OUTBOUND);
if ($resource->getAttribute('adapter') !== 'ssr') {
$queueForStatsUsage
->disableMetric(METRIC_EXECUTIONS)
->disableMetric(METRIC_EXECUTIONS_COMPUTE)
->disableMetric(METRIC_EXECUTIONS_MB_SECONDS)
->disableMetric($metricTypeExecutions)
->disableMetric($metricTypeIdExecutions)
->disableMetric($metricTypeExecutionsCompute)
->disableMetric($metricTypeIdExecutionsCompute)
->disableMetric($metricTypeExecutionsMbSeconds)
->disableMetric($metricTypeIdExecutionsMBSeconds);
}
$queueForStatsUsage
->addMetric(METRIC_SITES_REQUESTS, 1)
->addMetric(METRIC_SITES_INBOUND, $request->getSize() + $fileSize)
->addMetric(METRIC_SITES_OUTBOUND, $response->getSize())
->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_REQUESTS), 1)
->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_INBOUND), $request->getSize() + $fileSize)
->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_OUTBOUND), $response->getSize())
;
}
$compute = (int)($execution->getAttribute('duration') * 1000);
$mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT));
$queueForStatsUsage
->addMetric(METRIC_NETWORK_REQUESTS, 1)
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize)
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize())
->addMetric(METRIC_EXECUTIONS, 1)
->addMetric($metricTypeExecutions, 1)
->addMetric($metricTypeIdExecutions, 1)
->addMetric(METRIC_EXECUTIONS_COMPUTE, $compute) // per project
->addMetric($metricTypeExecutionsCompute, $compute) // per function
->addMetric($metricTypeIdExecutionsCompute, $compute) // per function
->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds)
->addMetric($metricTypeExecutionsMbSeconds, $mbSeconds)
->addMetric($metricTypeIdExecutionsMBSeconds, $mbSeconds)
->setProject($project)
->trigger();
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
deployment: $deployment->getArrayCopy(),
));
/* cleanup */
if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
@@ -881,9 +827,8 @@ Http::init()
->inject('locale')
->inject('localeCodes')
->inject('geodb')
->inject('queueForStatsUsage')
->inject('queueForEvents')
->inject('queueForExecutions')
->inject('bus')
->inject('executor')
->inject('platform')
->inject('isResourceBlocked')
@@ -894,7 +839,7 @@ Http::init()
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Execution $queueForExecutions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
@@ -902,7 +847,7 @@ Http::init()
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1178,8 +1123,7 @@ Http::options()
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('bus')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1192,14 +1136,14 @@ Http::options()
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1215,12 +1159,11 @@ Http::options()
/** OPTIONS requests in utopia do not execute shutdown handlers, as a result we need to track the OPTIONS requests explicitly
* @see https://github.com/utopia-php/http/blob/0.33.16/src/App.php#L825-L855
*/
$queueForStatsUsage
->addMetric(METRIC_NETWORK_REQUESTS, 1)
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize())
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize())
->setProject($project)
->trigger();
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
));
});
Http::error()
@@ -1231,10 +1174,10 @@ Http::error()
->inject('project')
->inject('logger')
->inject('log')
->inject('queueForStatsUsage')
->inject('bus')
->inject('devKey')
->inject('authorization')
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, StatsUsage $queueForStatsUsage, Document $devKey, Authorization $authorization) {
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$route = $utopia->getRoute();
$class = \get_class($error);
@@ -1307,21 +1250,12 @@ Http::error()
*/
if (!$publish && $project->getId() !== 'console') {
if (!DBUser::isPrivileged($authorization->getRoles())) {
$fileSize = 0;
$file = $request->getFiles('file');
if (!empty($file)) {
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
}
$queueForStatsUsage
->addMetric(METRIC_NETWORK_REQUESTS, 1)
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize)
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize());
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
));
}
$queueForStatsUsage
->setProject($project)
->trigger();
}
if ($logger && $publish) {
@@ -1568,8 +1502,7 @@ Http::get('/robots.txt')
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('bus')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1579,13 +1512,13 @@ Http::get('/robots.txt')
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1603,8 +1536,7 @@ Http::get('/humans.txt')
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('bus')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1614,13 +1546,13 @@ Http::get('/humans.txt')
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1718,7 +1650,10 @@ Http::get('/v1/ping')
->setAttribute('pingedAt', $pingedAt);
$authorization->skip(function () use ($dbForPlatform, $project) {
$dbForPlatform->updateDocument('projects', $project->getId(), $project);
$dbForPlatform->updateDocument('projects', $project->getId(), new Document([
'pingCount' => $project->getAttribute('pingCount'),
'pingedAt' => $project->getAttribute('pingedAt')
]));
});
$queueForEvents
+38 -25
View File
@@ -2,6 +2,7 @@
use Appwrite\Auth\Key;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Bus\Events\RequestCompleted;
use Appwrite\Event\Audit;
use Appwrite\Event\Build;
use Appwrite\Event\Database as EventDatabase;
@@ -21,12 +22,14 @@ use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Abuse\Abuse;
use Utopia\Bus\Bus;
use Utopia\Cache\Adapter\Filesystem;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
@@ -357,8 +360,9 @@ Http::init()
if (!$project->isEmpty() && $project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
$project->setAttribute('accessedAt', DateTime::now());
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project));
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
'accessedAt' => DateTime::now()
])));
}
}
@@ -368,9 +372,13 @@ Http::init()
$user->setAttribute('accessedAt', DateTime::now());
if ($project->getId() !== 'console' && APP_MODE_ADMIN !== $mode) {
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document([
'accessedAt' => $user->getAttribute('accessedAt')
]));
} else {
$authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), $user));
$authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), new Document([
'accessedAt' => $user->getAttribute('accessedAt')
])));
}
}
}
@@ -646,7 +654,9 @@ Http::init()
$transformedAt = $file->getAttribute('transformedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) {
$file->setAttribute('transformedAt', DateTime::now());
$authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), $file));
$authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), new Document([
'transformedAt' => $file->getAttribute('transformedAt')
])));
}
}
}
@@ -745,7 +755,8 @@ Http::shutdown()
->inject('authorization')
->inject('timelimit')
->inject('eventProcessor')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor) use ($parseLabel) {
->inject('bus')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -929,17 +940,24 @@ Http::shutdown()
$accessedAt = $cacheLog->getAttribute('accessedAt', 0);
$now = DateTime::now();
if ($cacheLog->isEmpty()) {
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
try {
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
} catch (DuplicateException) {
// Race condition: another concurrent request already created the cache document
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
}
} elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
$cacheLog->setAttribute('accessedAt', $now);
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog));
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([
'accessedAt' => $cacheLog->getAttribute('accessedAt')
])));
// Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load()
$cache->save($key, $data['payload']);
}
@@ -952,16 +970,11 @@ Http::shutdown()
if ($project->getId() !== 'console') {
if (!User::isPrivileged($authorization->getRoles())) {
$fileSize = 0;
$file = $request->getFiles('file');
if (!empty($file)) {
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
}
$queueForStatsUsage
->addMetric(METRIC_NETWORK_REQUESTS, 1)
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize)
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize());
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
));
}
$queueForStatsUsage
+4
View File
@@ -188,6 +188,10 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) {
Console::success('Reload completed...');
});
Http::setResource('bus', function ($register, $utopia) {
return $register->get('bus')->setResolver(fn (string $name) => $utopia->getResource($name));
}, ['register', 'utopia']);
include __DIR__ . '/controllers/general.php';
function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
+1
View File
@@ -6,6 +6,7 @@ const APP_NAME = 'Appwrite';
const APP_DOMAIN = 'appwrite.io';
const APP_VIEWS_DIR = __DIR__ . '/../views';
const APP_CE_CONFIG_DIR = __DIR__ . '/../config';
// Email
const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address
+4
View File
@@ -88,6 +88,10 @@ Database::addFilter(
break;
case Database::VAR_STRING:
case Database::VAR_VARCHAR:
case Database::VAR_TEXT:
case Database::VAR_MEDIUMTEXT:
case Database::VAR_LONGTEXT:
$filters = $attribute->getAttribute('filters', []);
$attribute->setAttribute('encrypt', in_array('encrypt', $filters));
break;
+8
View File
@@ -449,3 +449,11 @@ $register->set('promiseAdapter', function () {
$register->set('hooks', function () {
return new Hooks();
});
$listeners = require __DIR__ . '/../listeners.php';
$register->set('bus', function () use ($listeners) {
$bus = new \Utopia\Bus\Bus();
foreach ($listeners as $listener) {
$bus->subscribe($listener);
}
return $bus;
});
+10 -7
View File
@@ -10,7 +10,6 @@ use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Execution;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
@@ -160,9 +159,6 @@ Http::setResource('queueForAudits', function (Publisher $publisher) {
Http::setResource('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
Http::setResource('queueForExecutions', function (Publisher $publisher) {
return new Execution($publisher);
}, ['publisher']);
Http::setResource('eventProcessor', function () {
return new EventProcessor();
}, []);
@@ -1239,7 +1235,9 @@ Http::setResource('devKey', function (Request $request, Document $project, array
$accessedAt = $key->getAttribute('accessedAt', 0);
if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) {
$key->setAttribute('accessedAt', DatabaseDateTime::now());
$authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), $key));
$authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
'accessedAt' => $key->getAttribute('accessedAt')
])));
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
}
@@ -1256,7 +1254,10 @@ Http::setResource('devKey', function (Request $request, Document $project, array
/** Update access time as well */
$key->setAttribute('accessedAt', DatabaseDateTime::now());
$key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), $key));
$key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
'sdks' => $key->getAttribute('sdks'),
'accessedAt' => $key->getAttribute('accessedAt')
])));
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
}
}
@@ -1413,7 +1414,9 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request,
$accessedAt = $token->getAttribute('accessedAt', 0);
if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) {
$token->setAttribute('accessedAt', DatabaseDateTime::now());
$authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), $token));
$authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([
'accessedAt' => $token->getAttribute('accessedAt')
])));
}
return new Document([
+9
View File
@@ -0,0 +1,9 @@
<?php
use Appwrite\Bus\Listeners\Log;
use Appwrite\Bus\Listeners\Usage;
return [
new Log(),
new Usage(),
];
+73 -42
View File
@@ -248,48 +248,62 @@ $adapter
$server = new Server($adapter);
$logError = function (Throwable $error, string $action) use ($register) {
$logger = $register->get('realtimeLogger');
// Allows overriding
if (!function_exists('logError')) {
function logError(Throwable $error, string $action, array $tags = [], ?Document $project = null, ?Document $user = null, ?Authorization $authorization = null): void
{
global $register;
if ($logger && !$error instanceof Exception) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$logger = $register->get('realtimeLogger');
$log = new Log();
$log->setNamespace("realtime");
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
if ($logger && !$error instanceof Exception) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', get_class($error));
$log = new Log();
$log->setNamespace("realtime");
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', get_class($error));
$log->addTag('projectId', $project?->getId() ?: 'n/a');
$log->addTag('userId', $user?->getId() ?: 'n/a');
$log->setAction($action);
foreach ($tags as $key => $value) {
$log->addTag($key, $value ?: 'n/a');
}
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
$log->addExtra('detailedTrace', $error->getTrace());
$log->addExtra('roles', $authorization?->getRoles() ?? []);
try {
$responseCode = $logger->addLog($log);
Console::info('Error log pushed with status code: ' . $responseCode);
} catch (Throwable $th) {
Console::error('Error pushing log: ' . $th->getMessage());
$log->setAction($action);
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
try {
$responseCode = $logger->addLog($log);
Console::info('Error log pushed with status code: ' . $responseCode);
} catch (Throwable $th) {
Console::error('Error pushing log: ' . $th->getMessage());
}
}
Console::error('[Error] Type: ' . get_class($error));
Console::error('[Error] Message: ' . $error->getMessage());
Console::error('[Error] File: ' . $error->getFile());
Console::error('[Error] Line: ' . $error->getLine());
}
}
Console::error('[Error] Type: ' . get_class($error));
Console::error('[Error] Message: ' . $error->getMessage());
Console::error('[Error] File: ' . $error->getFile());
Console::error('[Error] Line: ' . $error->getLine());
};
$server->error(logError(...));
$server->error($logError);
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument, $logError) {
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) {
sleep(5); // wait for the initial database schema to be ready
Console::success('Server started successfully');
@@ -326,7 +340,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
*/
// TODO: Remove this if check once it doesn't cause issues for cloud
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
Timer::tick(5000, function () use ($register, $stats, &$statsDocument, $logError) {
Timer::tick(5000, function () use ($register, $stats, &$statsDocument) {
$payload = [];
foreach ($stats as $projectId => $value) {
$payload[$projectId] = $stats->get($projectId, 'connectionsTotal');
@@ -342,15 +356,18 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
->setAttribute('timestamp', DateTime::now())
->setAttribute('value', json_encode($payload));
$database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), $statsDocument));
$database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), new Document([
'timestamp' => $statsDocument->getAttribute('timestamp'),
'value' => $statsDocument->getAttribute('value')
])));
} catch (Throwable $th) {
$logError($th, "updateWorkerDocument");
logError($th, "updateWorkerDocument");
}
});
}
});
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime, $logError) {
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) {
Console::success('Worker ' . $workerId . ' started successfully');
$telemetry = getTelemetry($workerId);
@@ -362,7 +379,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$attempts = 0;
$start = time();
Timer::tick(5000, function () use ($server, $register, $realtime, $stats, $logError) {
Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
/**
* Sending current connections to project channels on the console project every 5 seconds.
*/
@@ -548,7 +565,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
}
});
} catch (Throwable $th) {
$logError($th, "pubSubConnection");
logError($th, "pubSubConnection");
Console::error('Pub/sub error: ' . $th->getMessage());
$attempts++;
@@ -560,7 +577,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::error('Failed to restart pub/sub...');
});
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $logError) {
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
$app = new Http('UTC');
$request = new Request($request);
$response = new Response(new SwooleResponse());
@@ -571,6 +588,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
Http::setResource('request', fn () => $request);
Http::setResource('response', fn () => $response);
$project = null;
$logUser = null;
$authorization = null;
try {
/** @var Document $project */
$project = $app->getResource('project');
@@ -591,8 +612,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
$projectRegion = $project->getAttribute('region', '');
$currentRegion = System::getEnv('_APP_REGION', 'default');
if (!empty($projectRegion) && $projectRegion !== $currentRegion) {
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint');
}
$timelimit = $app->getResource('timelimit');
$user = $app->getResource('user'); /** @var User $user */
$logUser = $user;
/*
* Abuse Check
@@ -683,11 +711,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$stats->incr($project->getId(), 'connections');
$stats->incr($project->getId(), 'connectionsTotal');
} catch (Throwable $th) {
$logError($th, "initServer");
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
// Handle SQL error code is 'HY000'
$code = $th->getCode();
if (!is_int($code)) {
if (!\is_int($code)) {
$code = 500;
}
@@ -718,7 +746,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
}
});
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId, $logError) {
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
$project = null;
$authorization = null;
try {
$response = new Response(new SwooleResponse());
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
@@ -844,7 +875,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
} catch (Throwable $th) {
$logError($th, "realtimeMessage");
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$code = $th->getCode();
if (!is_int($code)) {
$code = 500;
+4 -4
View File
@@ -9,7 +9,6 @@ use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Execution;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
@@ -355,9 +354,6 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
Server::setResource('queueForExecutions', function (Publisher $publisher) {
return new Execution($publisher);
}, ['publisher']);
Server::setResource('queueForRealtime', function () {
return new Realtime();
@@ -542,6 +538,10 @@ try {
$worker = $platform->getWorker();
Server::setResource('bus', function ($register) use ($worker) {
return $register->get('bus')->setResolver(fn (string $name) => $worker->getResource($name));
}, ['register']);
$worker
->error()
->inject('error')
+4 -2
View File
@@ -19,7 +19,8 @@
"autoload": {
"psr-4": {
"Appwrite\\": "src/Appwrite",
"Executor\\": "src/Executor"
"Executor\\": "src/Executor",
"Utopia\\Bus\\": "src/Utopia/Bus"
}
},
"autoload-dev": {
@@ -79,7 +80,7 @@
"utopia-php/storage": "1.0.*",
"utopia-php/system": "0.10.*",
"utopia-php/telemetry": "0.2.*",
"utopia-php/vcs": "1.*",
"utopia-php/vcs": "2.*",
"utopia-php/websocket": "1.0.*",
"matomo/device-detector": "6.4.*",
"dragonmantank/cron-expression": "3.4.*",
@@ -99,6 +100,7 @@
"swoole/ide-helper": "6.*",
"phpstan/phpstan": "1.12.*",
"textalk/websocket": "1.5.*",
"czproject/git-php": "4.*",
"laravel/pint": "1.*",
"phpbench/phpbench": "1.*"
},
Generated
+101 -37
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": "cb2ca42cfed8603c31b75113c85a4699",
"content-hash": "1cc64e07484256225f56bd525674c3b8",
"packages": [
{
"name": "adhocore/jwt",
@@ -4517,16 +4517,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.6.1",
"version": "1.6.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "c5c7544d02d2418536d41050794050132f247d62"
"reference": "c2d016944cb029fa5ff822ceee704785a06ef289"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c5c7544d02d2418536d41050794050132f247d62",
"reference": "c5c7544d02d2418536d41050794050132f247d62",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c2d016944cb029fa5ff822ceee704785a06ef289",
"reference": "c2d016944cb029fa5ff822ceee704785a06ef289",
"shasum": ""
},
"require": {
@@ -4566,9 +4566,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.6.1"
"source": "https://github.com/utopia-php/migration/tree/1.6.3"
},
"time": "2026-02-17T05:49:48+00:00"
"time": "2026-03-04T07:08:22+00:00"
},
{
"name": "utopia-php/mongo",
@@ -4684,16 +4684,16 @@
},
{
"name": "utopia-php/pools",
"version": "1.0.2",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/pools.git",
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1"
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"shasum": ""
},
"require": {
@@ -4731,9 +4731,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/pools/issues",
"source": "https://github.com/utopia-php/pools/tree/1.0.2"
"source": "https://github.com/utopia-php/pools/tree/1.0.3"
},
"time": "2026-01-28T13:12:36+00:00"
"time": "2026-02-26T08:42:40+00:00"
},
{
"name": "utopia-php/preloader",
@@ -5215,16 +5215,16 @@
},
{
"name": "utopia-php/vcs",
"version": "1.0.1",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
"reference": "0f942f667319bb22cd420431e4e79107b41061f3"
"reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/0f942f667319bb22cd420431e4e79107b41061f3",
"reference": "0f942f667319bb22cd420431e4e79107b41061f3",
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/92a1650824ba0c5e6a1bc46e622ac87c50a08920",
"reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920",
"shasum": ""
},
"require": {
@@ -5258,9 +5258,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/vcs/issues",
"source": "https://github.com/utopia-php/vcs/tree/1.0.1"
"source": "https://github.com/utopia-php/vcs/tree/2.0.1"
},
"time": "2026-02-06T10:11:58+00:00"
"time": "2026-02-27T12:18:49+00:00"
},
{
"name": "utopia-php/websocket",
@@ -5438,16 +5438,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "1.11.1",
"version": "1.11.3",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb"
"reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6ff411f26f2750eea05c7598c14bb3a2ada898cb",
"reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/45d22c0107a53bb9a0a4e39db0e738d461631d11",
"reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11",
"shasum": ""
},
"require": {
@@ -5483,22 +5483,22 @@
"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.11.1"
"source": "https://github.com/appwrite/sdk-generator/tree/1.11.3"
},
"time": "2026-02-25T07:15:19+00:00"
"time": "2026-02-27T06:54:59+00:00"
},
{
"name": "brianium/paratest",
"version": "v7.19.0",
"version": "v7.19.1",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6"
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/95b03194f4cdf5c83175ceead673e21cb66465e7",
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7",
"shasum": ""
},
"require": {
@@ -5512,7 +5512,7 @@
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
"phpunit/php-file-iterator": "^6.0.1 || ^7",
"phpunit/php-timer": "^8 || ^9",
"phpunit/phpunit": "^12.5.9 || ^13",
"phpunit/phpunit": "^12.5.14 || ^13.0.5",
"sebastian/environment": "^8.0.3 || ^9",
"symfony/console": "^7.4.4 || ^8.0.4",
"symfony/process": "^7.4.5 || ^8.0.5"
@@ -5522,10 +5522,10 @@
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
"phpstan/phpstan": "^2.1.38",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.12",
"phpstan/phpstan-strict-rules": "^2.0.8",
"phpstan/phpstan": "^2.1.40",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
"symfony/filesystem": "^7.4.0 || ^8.0.1"
},
"bin": [
@@ -5566,7 +5566,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.19.0"
"source": "https://github.com/paratestphp/paratest/tree/v7.19.1"
},
"funding": [
{
@@ -5578,7 +5578,71 @@
"type": "paypal"
}
],
"time": "2026-02-06T10:53:26+00:00"
"time": "2026-02-25T14:53:45+00:00"
},
{
"name": "czproject/git-php",
"version": "v4.6.0",
"source": {
"type": "git",
"url": "https://github.com/czproject/git-php.git",
"reference": "1f1ecc92aea9ee31120f4f5b759f5aa947420b0a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/czproject/git-php/zipball/1f1ecc92aea9ee31120f4f5b759f5aa947420b0a",
"reference": "1f1ecc92aea9ee31120f4f5b759f5aa947420b0a",
"shasum": ""
},
"require": {
"php": "8.0 - 8.5"
},
"require-dev": {
"nette/tester": "^2.5"
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Jan Pecha",
"email": "janpecha@email.cz"
}
],
"description": "Library for work with Git repository in PHP.",
"keywords": [
"git"
],
"support": {
"issues": "https://github.com/czproject/git-php/issues",
"source": "https://github.com/czproject/git-php/tree/v4.6.0"
},
"funding": [
{
"url": "https://github.com/sponsors/janpecha",
"type": "github"
},
{
"url": "https://www.janpecha.cz/donate/git-php/",
"type": "other"
},
{
"url": "https://donate.stripe.com/7sIcO2a9maTSg2A9AA",
"type": "stripe"
},
{
"url": "https://thanks.dev/u/gh/czproject",
"type": "thanks.dev"
}
],
"time": "2025-11-10T07:24:07+00:00"
},
{
"name": "doctrine/annotations",
@@ -9067,5 +9131,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
-18
View File
@@ -152,7 +152,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_SMTP_HOST
- _APP_SMTP_PORT
- _APP_SMTP_SECURE
@@ -305,7 +304,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_USAGE_STATS
- _APP_LOGGING_CONFIG
- _APP_LOGGING_CONFIG_REALTIME
@@ -340,7 +338,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
@@ -371,7 +368,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -414,7 +410,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_STORAGE_DEVICE
- _APP_STORAGE_S3_ACCESS_KEY
- _APP_STORAGE_S3_SECRET
@@ -474,7 +469,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_WORKERS_NUM
- _APP_QUEUE_NAME
@@ -513,7 +507,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_VCS_GITHUB_APP_NAME
- _APP_VCS_GITHUB_PRIVATE_KEY
@@ -658,7 +651,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
@@ -722,7 +714,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_FUNCTIONS_TIMEOUT
- _APP_SITES_TIMEOUT
- _APP_COMPUTE_BUILD_TIMEOUT
@@ -808,7 +799,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_SMS_FROM
- _APP_SMS_PROVIDER
@@ -875,7 +865,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
@@ -918,7 +907,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_RETENTION_EXECUTION
- _APP_MAINTENANCE_RETENTION_CACHE
@@ -994,7 +982,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1028,7 +1015,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1062,7 +1048,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1100,7 +1085,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_DATABASE_SHARED_TABLES
appwrite-task-scheduler-executions:
@@ -1131,7 +1115,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
appwrite-task-scheduler-messages:
entrypoint: schedule-messages
@@ -1161,7 +1144,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_DATABASE_SHARED_TABLES
appwrite-assistant:
+4
View File
@@ -1,7 +1,11 @@
parameters:
level: 8
paths:
- src/Utopia/Bus
- src/Appwrite/Bus
- src/Appwrite/Transformation
bootstrapFiles:
- app/init/constants.php
scanDirectories:
- vendor/swoole/ide-helper
excludePaths:
+1
View File
@@ -6,6 +6,7 @@
colors="true"
processIsolation="false"
stopOnFailure="false"
stopOnError="false"
cacheDirectory=".phpunit.cache"
>
<extensions>
@@ -0,0 +1,22 @@
<?php
namespace Appwrite\Bus\Events;
use Utopia\Bus\Event;
class ExecutionCompleted implements Event
{
/**
* @param array<string, mixed> $execution
* @param array<string, mixed> $project
* @param array<string, mixed> $spec
* @param array<string, mixed> $resource
*/
public function __construct(
public readonly array $execution,
public readonly array $project,
public readonly array $spec = [],
public readonly array $resource = [],
) {
}
}
@@ -0,0 +1,22 @@
<?php
namespace Appwrite\Bus\Events;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Bus\Event;
class RequestCompleted implements Event
{
/**
* @param array<string, mixed> $project
* @param array<string, mixed> $deployment
*/
public function __construct(
public readonly array $project,
public readonly Request $request,
public readonly Response $response,
public readonly array $deployment = [],
) {
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Appwrite\Bus\Listeners;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Event\Execution;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
use Utopia\Queue\Publisher;
class Log extends Listener
{
public static function getName(): string
{
return 'log';
}
public static function getEvents(): array
{
return [ExecutionCompleted::class];
}
public function __construct()
{
$this
->desc('Persists execution logs to database via queue')
->inject('publisher')
->callback($this->handle(...));
}
public function handle(ExecutionCompleted $event, Publisher $publisher): void
{
$queueForExecutions = new Execution($publisher);
$queueForExecutions
->setExecution(new Document($event->execution))
->setProject(new Document($event->project))
->trigger();
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Appwrite\Bus\Listeners;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Bus\Events\RequestCompleted;
use Appwrite\Event\StatsUsage;
use Utopia\Bus\Event;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
use Utopia\Queue\Publisher;
class Usage extends Listener
{
public static function getName(): string
{
return 'usage';
}
public static function getEvents(): array
{
return [
ExecutionCompleted::class,
RequestCompleted::class,
];
}
public function __construct()
{
$this
->desc('Records usage metrics')
->inject('publisherStatsUsage')
->callback($this->handle(...));
}
public function handle(Event $event, Publisher $publisher): void
{
match (true) {
$event instanceof ExecutionCompleted => $this->handleExecutionCompleted($event, $publisher),
$event instanceof RequestCompleted => $this->handleRequestCompleted($event, $publisher),
default => null,
};
}
private function handleExecutionCompleted(ExecutionCompleted $event, Publisher $publisher): void
{
$execution = new Document($event->execution);
$resource = new Document($event->resource);
// Non-SSR sites don't record execution metrics
if ($execution->getAttribute('resourceType') === 'sites' && $resource->getAttribute('adapter') !== 'ssr') {
return;
}
$project = new Document($event->project);
$spec = $event->spec;
$resourceType = $execution->getAttribute('resourceType', '');
$resourceInternalId = $execution->getAttribute('resourceInternalId', '');
$duration = $execution->getAttribute('duration', 0);
$compute = (int)($duration * 1000);
$mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $duration * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT));
$queueForStatsUsage = new StatsUsage($publisher);
$queueForStatsUsage
->setProject($project)
->addMetric(METRIC_EXECUTIONS, 1)
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS), 1)
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS), 1)
->addMetric(METRIC_EXECUTIONS_COMPUTE, $compute)
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE), $compute)
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE), $compute)
->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds)
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), $mbSeconds)
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), $mbSeconds)
->trigger();
}
private function handleRequestCompleted(RequestCompleted $event, Publisher $publisher): void
{
$fileSize = 0;
$file = $event->request->getFiles('file');
if (!empty($file)) {
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
}
$project = new Document($event->project);
$deployment = new Document($event->deployment);
$queueForStatsUsage = new StatsUsage($publisher);
$inbound = $event->request->getSize() + $fileSize;
$outbound = $event->response->getSize();
$queueForStatsUsage->setProject($project);
if ($deployment->getAttribute('resourceType') === 'sites') {
$siteInternalId = $deployment->getAttribute('resourceInternalId', '');
$queueForStatsUsage
->addMetric(METRIC_SITES_REQUESTS, 1)
->addMetric(METRIC_SITES_INBOUND, $inbound)
->addMetric(METRIC_SITES_OUTBOUND, $outbound)
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_REQUESTS), 1)
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_INBOUND), $inbound)
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_OUTBOUND), $outbound);
} else {
$queueForStatsUsage
->addMetric(METRIC_NETWORK_REQUESTS, 1)
->addMetric(METRIC_NETWORK_INBOUND, $inbound)
->addMetric(METRIC_NETWORK_OUTBOUND, $outbound);
}
$queueForStatsUsage->trigger();
}
}
+66 -8
View File
@@ -518,10 +518,11 @@ class Event
*
* @param string $pattern
* @param array $params
* @param ?Document $database
* @return array
* @throws \InvalidArgumentException
*/
public static function generateEvents(string $pattern, array $params = []): array
public static function generateEvents(string $pattern, array $params = [], ?Document $database = null): array
{
// $params = \array_filter($params, fn($param) => !\is_array($param));
$paramKeys = \array_keys($params);
@@ -530,6 +531,12 @@ class Event
$patterns = [];
$parsed = self::parseEventPattern($pattern);
// to switch the resource types from databases to the required prefix
// eg; all databases events get fired with databases. prefix which mainly depicts legacy type
// so a projection from databases to the actual prefix
if ((str_contains($pattern, 'databases.') && $database && $database->getAttribute('type') !== 'legacy')) {
$parsed = self::getDatabaseTypeEvents($database, $parsed);
}
$type = $parsed['type'];
$resource = $parsed['resource'];
$subType = $parsed['subType'];
@@ -630,8 +637,8 @@ class Event
$eventValues = \array_values($events);
/**
* Return a combined list of table, collection events.
*/
* Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility
*/
return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues);
}
@@ -671,16 +678,41 @@ class Event
'attributes' => 'columns',
];
$databasesEventMap = [
'tablesdb' => 'databases',
'tables' => 'collections',
'rows' => 'documents',
'columns' => 'attributes'
];
if (
str_contains($pattern, 'databases.') &&
str_contains($firstEvent, 'collections')
(
str_contains($pattern, 'databases.') &&
str_contains($firstEvent, 'collections')
) ||
(
str_contains($firstEvent, 'tablesdb.')
)
) {
$pairedEvents = [];
foreach ($events as $event) {
$pairedEvents[] = $event;
if (str_contains($event, 'collections')) {
// tablesdb needs databases event with tables and collections
if (str_contains($event, 'tablesdb')) {
$databasesSideEvent = str_replace(
array_keys($databasesEventMap),
array_values($databasesEventMap),
$event
);
$pairedEvents[] = $databasesSideEvent;
$tableSideEvent = str_replace(
array_keys($tableEventMap),
array_values($tableEventMap),
$databasesSideEvent
);
$pairedEvents[] = $tableSideEvent;
} elseif (str_contains($event, 'collections')) {
$tableSideEvent = str_replace(
array_keys($tableEventMap),
array_values($tableEventMap),
@@ -692,8 +724,34 @@ class Event
$events = $pairedEvents;
}
// mirrored events can have duplicates in case of smaller events
// array unique can turns list to hasmap in case duplicates present
// so forcing array value will turn this to array list always
return array_values(array_unique($events));
}
return $events;
/**
* Maps event terminology based on database type
*/
private static function getDatabaseTypeEvents(Document $database, array $event): array
{
$eventMap = [];
switch ($database->getAttribute('type')) {
case 'tablesdb':
$eventMap = [
'databases' => 'tablesdb',
'documents' => 'rows',
'collections' => 'tables',
'attributes' => 'columns',
];
break;
}
foreach ($event as $eventKey => $eventValue) {
if (isset($eventMap[$eventValue])) {
$event[$eventKey] = $eventMap[$eventValue];
}
}
return $event;
}
/**
+1 -1
View File
@@ -72,7 +72,7 @@ class Realtime extends Event
return false;
}
$allEvents = Event::generateEvents($this->getEvent(), $this->getParams());
$allEvents = Event::generateEvents($this->getEvent(), $this->getParams(), $this->getContext('database'));
$payload = new Document($this->getPayload());
+2
View File
@@ -166,6 +166,7 @@ class Exception extends \Exception
/** Sites */
public const string SITE_NOT_FOUND = 'site_not_found';
public const string SITE_ALREADY_EXISTS = 'site_already_exists';
public const string SITE_TEMPLATE_NOT_FOUND = 'site_template_not_found';
/** Functions */
@@ -365,6 +366,7 @@ class Exception extends \Exception
/** Message */
public const string MESSAGE_NOT_FOUND = 'message_not_found';
public const string MESSAGE_ALREADY_EXISTS = 'message_already_exists';
public const string MESSAGE_MISSING_TARGET = 'message_missing_target';
public const string MESSAGE_ALREADY_SENT = 'message_already_sent';
public const string MESSAGE_ALREADY_PROCESSING = 'message_already_processing';
+60 -7
View File
@@ -491,6 +491,7 @@ class Realtime extends MessagingAdapter
$roles = [Role::team(ID::custom($parts[1]))->toString()];
break;
case 'databases':
case 'tablesdb':
$resource = $parts[4] ?? '';
if (in_array($resource, ['columns', 'attributes', 'indexes'])) {
$channels[] = 'console';
@@ -508,14 +509,14 @@ class Realtime extends MessagingAdapter
$tableId = $payload->getAttribute('$tableId', '');
$collectionId = $payload->getAttribute('$collectionId', '');
$resourceId = $tableId ?: $collectionId;
$channels = [];
$channels[] = 'rows';
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows';
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows.' . $payload->getId();
$channels[] = 'documents';
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents';
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents.' . $payload->getId();
// sending legacy + tablesdb events to both legacy and tablesdb
$channels = array_values(array_unique(array_merge(
self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'),
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'),
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId())
)));
$roles = $collection->getAttribute('documentSecurity', false)
? \array_merge($collection->getRead(), $payload->getRead())
@@ -572,4 +573,56 @@ class Realtime extends MessagingAdapter
'projectId' => $projectId
];
}
/**
* Generate realtime channels for database events
*
* @param string $type The database API type
* @param string $databaseId The database ID
* @param string $resourceId The collection/table ID
* @param string $payloadId The document/row ID
* @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes
* @return array Array of channel names
*/
private static function getDatabaseChannels(
string $type = 'databases',
string $databaseId = '',
string $resourceId = '',
string $payloadId = '',
string $prefixOverride = '',
): array {
$basePrefix = $prefixOverride ?: $type;
if (!$databaseId || !$resourceId || !$payloadId) {
return [];
}
$channels = [];
switch ($type) {
case 'legacy':
if (empty($prefixOverride)) {
$basePrefix = 'databases';
}
$channels[] = 'documents';
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
break;
case 'tablesdb':
$channels[] = 'rows';
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows";
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows.{$payloadId}";
break;
default:
$basePrefix = 'databases';
$channels[] = 'documents';
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
break;
}
return $channels;
}
}
+2
View File
@@ -13,6 +13,7 @@ use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
use Appwrite\Platform\Modules\Sites;
use Appwrite\Platform\Modules\Storage;
use Appwrite\Platform\Modules\Teams;
use Appwrite\Platform\Modules\Tokens;
use Appwrite\Platform\Modules\VCS;
use Utopia\Platform\Platform;
@@ -31,6 +32,7 @@ class Appwrite extends Platform
$this->addModule(new Sites\Module());
$this->addModule(new Console\Module());
$this->addModule(new Proxy\Module());
$this->addModule(new Teams\Module());
$this->addModule(new Tokens\Module());
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
@@ -119,7 +119,7 @@ class Update extends Action
$authenticator->setAttribute('verified', true);
$dbForProject->updateDocument('authenticators', $authenticator->getId(), $authenticator);
$dbForProject->updateDocument('authenticators', $authenticator->getId(), new Document(['verified' => true]));
$dbForProject->purgeCachedDocument('users', $user->getId());
$factors = $session->getAttribute('factors', []);
@@ -127,7 +127,7 @@ class Update extends Action
$factors = \array_values(\array_unique($factors));
$session->setAttribute('factors', $factors);
$dbForProject->updateDocument('sessions', $session->getId(), $session);
$dbForProject->updateDocument('sessions', $session->getId(), new Document(['factors' => $factors]));
$queueForEvents->setParam('userId', $user->getId());
@@ -117,7 +117,7 @@ class Update extends Action
$mfaRecoveryCodes = \array_diff($mfaRecoveryCodes, [$otp]);
$mfaRecoveryCodes = \array_values($mfaRecoveryCodes);
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes]));
return true;
}
@@ -147,11 +147,13 @@ class Update extends Action
$factors[] = $type;
$factors = \array_values(\array_unique($factors));
$mfaUpdatedAt = DateTime::now();
$session
->setAttribute('factors', $factors)
->setAttribute('mfaUpdatedAt', DateTime::now());
->setAttribute('mfaUpdatedAt', $mfaUpdatedAt);
$dbForProject->updateDocument('sessions', $session->getId(), $session);
$dbForProject->updateDocument('sessions', $session->getId(), new Document(['factors' => $factors, 'mfaUpdatedAt' => $mfaUpdatedAt]));
$queueForEvents
->setParam('userId', $user->getId())
@@ -93,7 +93,7 @@ class Create extends Action
$mfaRecoveryCodes = Type::generateBackupCodes();
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes]));
$queueForEvents->setParam('userId', $user->getId());
@@ -91,7 +91,7 @@ class Update extends Action
$mfaRecoveryCodes = Type::generateBackupCodes();
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes]));
$queueForEvents->setParam('userId', $user->getId());
@@ -72,7 +72,7 @@ class Update extends Action
): void {
$user->setAttribute('mfa', $mfa);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['mfa' => $mfa]));
if ($mfa) {
$factors = $session->getAttribute('factors', []);
@@ -89,7 +89,7 @@ class Update extends Action
$factors = \array_values(\array_unique($factors));
$session->setAttribute('factors', $factors);
$dbForProject->updateDocument('sessions', $session->getId(), $session);
$dbForProject->updateDocument('sessions', $session->getId(), new Document(['factors' => $factors]));
}
$queueForEvents->setParam('userId', $user->getId());
+12 -2
View File
@@ -143,7 +143,12 @@ class Base extends Action
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentId' => $deployment->getId(),
'latestDeploymentInternalId' => $deployment->getSequence(),
'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
$queueForBuilds
->setType(BUILD_TYPE_DEPLOYMENT)
@@ -249,7 +254,12 @@ class Base extends Action
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('sites', $site->getId(), $site);
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'latestDeploymentId' => $deployment->getId(),
'latestDeploymentInternalId' => $deployment->getSequence(),
'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
@@ -12,6 +12,7 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\IndexDependency as IndexDependencyValidator;
use Utopia\Database\Validator\Key;
@@ -98,7 +99,8 @@ class Delete extends Action
}
if ($attribute->getAttribute('status') === 'available') {
$attribute = $dbForProject->updateDocument('attributes', $attribute->getId(), $attribute->setAttribute('status', 'deleting'));
$attribute->setAttribute('status', 'deleting');
$attribute = $dbForProject->updateDocument('attributes', $attribute->getId(), new Document(['status' => 'deleting']));
}
$dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId);
@@ -118,7 +120,8 @@ class Delete extends Action
}
if ($relatedAttribute->getAttribute('status') === 'available') {
$dbForProject->updateDocument('attributes', $relatedAttribute->getId(), $relatedAttribute->setAttribute('status', 'deleting'));
$relatedAttribute->setAttribute('status', 'deleting');
$dbForProject->updateDocument('attributes', $relatedAttribute->getId(), new Document(['status' => 'deleting']));
}
$dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $options['relatedCollection']);
@@ -24,6 +24,7 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends Action
@@ -70,15 +71,17 @@ class XList extends Action
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true)
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('queueForStatsUsage')
->inject('transactionState')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
@@ -129,9 +132,73 @@ class XList extends Action
$documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries);
$total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0;
} elseif (! empty($selectQueries)) {
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
if ((int)$ttl > 0) {
$serializedQueries = [];
foreach ($queries as $query) {
$serializedQueries[] = $query instanceof Query ? $query->toArray() : $query;
}
$hostname = $dbForProject->getAdapter()->getHostname();
$roles = $dbForProject->getAuthorization()->getRoles();
$schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', [])));
$cacheKeyBase = \sprintf(
'%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s',
$dbForProject->getCacheName(),
$hostname ?? '',
$dbForProject->getNamespace(),
$dbForProject->getTenant(),
$collectionId,
$schemaHash,
\md5(\json_encode($roles)),
\md5(\json_encode($serializedQueries))
);
$documentsCacheKey = $cacheKeyBase . ':documents';
$totalCacheKey = $cacheKeyBase . ':total';
$documentsCacheHit = $totalDocumentsCacheHit = false;
$cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl);
if ($cachedDocuments !== null &&
$cachedDocuments !== false &&
\is_array($cachedDocuments)) {
$documents = \array_map(function ($doc) {
return new Document($doc);
}, $cachedDocuments);
$documentsCacheHit = true;
} else {
$documents = $dbForProject->find($collectionTableId, $queries);
// Convert Document objects to arrays for caching
$documentsArray = \array_map(function ($doc) {
return $doc->getArrayCopy();
}, $documents);
$dbForProject->getCache()->save($documentsCacheKey, $documentsArray);
}
if ($includeTotal) {
$cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl);
if ($cachedTotal !== null && $cachedTotal !== false) {
$total = $cachedTotal;
$totalDocumentsCacheHit = true;
} else {
$total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT);
$dbForProject->getCache()->save($totalCacheKey, $total);
}
} else {
$total = 0;
}
$response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss');
} else {
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
}
} else {
// has no selects, disable relationship loading on documents
/* @type Document[] $documents */
@@ -12,6 +12,7 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Key;
use Utopia\Database\Validator\UID;
@@ -96,7 +97,8 @@ class Delete extends Action
// Only update status if removing available index
if ($index->getAttribute('status') === 'available') {
$index = $dbForProject->updateDocument('indexes', $index->getId(), $index->setAttribute('status', 'deleting'));
$index->setAttribute('status', 'deleting');
$index = $dbForProject->updateDocument('indexes', $index->getId(), new Document(['status' => 'deleting']));
}
$dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId);
@@ -14,6 +14,7 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends DocumentXList
@@ -56,8 +57,10 @@ class XList extends DocumentXList
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true)
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('queueForStatsUsage')
->inject('transactionState')
->inject('authorization')
@@ -322,13 +322,19 @@ class Databases extends Action
$dbForProject->updateDocument(
'attributes',
$attribute->getId(),
$attribute->setAttribute('status', 'stuck')
new Document([
'error' => $attribute->getAttribute('error'),
'status' => 'stuck',
])
);
if (!$relatedAttribute->isEmpty()) {
$dbForProject->updateDocument(
'attributes',
$relatedAttribute->getId(),
$relatedAttribute->setAttribute('status', 'stuck')
new Document([
'error' => $relatedAttribute->getAttribute('error'),
'status' => 'stuck',
])
);
}
@@ -382,7 +388,11 @@ class Databases extends Action
if ($exists) { // Delete the duplicate if created, else update in db
$this->deleteIndex($database, $collection, $index, $project, $dbForPlatform, $dbForProject, $queueForRealtime);
} else {
$dbForProject->updateDocument('indexes', $index->getId(), $index);
$dbForProject->updateDocument('indexes', $index->getId(), new Document([
'attributes' => $index->getAttribute('attributes'),
'lengths' => $index->getAttribute('lengths'),
'orders' => $index->getAttribute('orders'),
]));
}
}
}
@@ -227,7 +227,9 @@ class Create extends Action
foreach ($activeDeployments as $activeDeployment) {
$activeDeployment->setAttribute('activate', false);
$dbForProject->updateDocument('deployments', $activeDeployment->getId(), $activeDeployment);
$dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document([
'activate' => false,
]));
}
}
@@ -255,14 +257,17 @@ class Create extends Action
'type' => $type
]));
$function = $function
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentId' => $deployment->getId(),
'latestDeploymentInternalId' => $deployment->getSequence(),
'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceSize', $fileSize)->setAttribute('sourceMetadata', $metadata));
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceSize' => $fileSize,
'sourceMetadata' => $metadata,
]));
}
// Start the build
@@ -295,14 +300,17 @@ class Create extends Action
'type' => $type
]));
$function = $function
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentId' => $deployment->getId(),
'latestDeploymentInternalId' => $deployment->getSequence(),
'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceChunksUploaded', $chunksUploaded)->setAttribute('sourceMetadata', $metadata));
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceChunksUploaded' => $chunksUploaded,
'sourceMetadata' => $metadata,
]));
}
}
@@ -107,11 +107,12 @@ class Delete extends Action
$function = $dbForProject->updateDocument(
'functions',
$function->getId(),
$function
->setAttribute('latestDeploymentCreatedAt', $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt())
->setAttribute('latestDeploymentInternalId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence())
->setAttribute('latestDeploymentId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getId())
->setAttribute('latestDeploymentStatus', $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', ''))
new Document([
'latestDeploymentCreatedAt' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt(),
'latestDeploymentInternalId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence(),
'latestDeploymentId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getId(),
'latestDeploymentStatus' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', ''),
])
);
}
@@ -10,6 +10,7 @@ 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\Helpers\ID;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
@@ -119,7 +120,12 @@ class Create extends Action
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentId' => $function->getAttribute('latestDeploymentId'),
'latestDeploymentInternalId' => $function->getAttribute('latestDeploymentInternalId'),
'latestDeploymentCreatedAt' => $function->getAttribute('latestDeploymentCreatedAt'),
'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'),
]));
$queueForBuilds
->setType(BUILD_TYPE_DEPLOYMENT)
@@ -91,7 +91,7 @@ class Update extends Action
$endTime = new \DateTime('now');
$duration = $endTime->getTimestamp() - $startTime->getTimestamp();
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment->setAttributes([
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildEndedAt' => DateTime::now(),
'buildDuration' => $duration,
'status' => 'canceled'
@@ -99,7 +99,9 @@ class Update extends Action
if ($deployment->getSequence() === $function->getAttribute('latestDeploymentInternalId', '')) {
$function = $function->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'),
]));
}
try {
@@ -174,7 +174,12 @@ class Create extends Base
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentId' => $function->getAttribute('latestDeploymentId'),
'latestDeploymentInternalId' => $function->getAttribute('latestDeploymentInternalId'),
'latestDeploymentCreatedAt' => $function->getAttribute('latestDeploymentCreatedAt'),
'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'),
]));
$this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization);
@@ -12,6 +12,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
@@ -90,6 +91,15 @@ class Delete extends Base
}
$status = $execution->getAttribute('status');
// Treat timed-out executions as failed so they can be deleted.
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$status = 'failed';
}
}
if (!in_array($status, ['completed', 'failed', 'scheduled'])) {
throw new Exception(Exception::EXECUTION_IN_PROGRESS);
}
@@ -110,7 +120,10 @@ class Delete extends Base
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('active', false);
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([
'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'),
'active' => $schedule->getAttribute('active'),
])));
}
}
@@ -82,6 +82,16 @@ class Get extends Base
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
// Override status in response if the execution is stuck in waiting/processing beyond the function timeout.
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
$response->dynamic($execution, Response::MODEL_EXECUTION);
}
}
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -110,6 +111,35 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing execution is considered timed out.
$timeout = $function->getAttribute('timeout', 900);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing executions created before the timeout threshold, so timed-out
// executions that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -119,6 +149,20 @@ class XList extends Base
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.");
}
// Override status in response for timed-out executions, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $execution) {
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -283,7 +283,12 @@ class Create extends Base
$function->setAttribute('repositoryInternalId', $repository->getSequence());
}
$function = $dbForProject->updateDocument('functions', $function->getId(), $function);
$function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
'scheduleId' => $function->getAttribute('scheduleId'),
'scheduleInternalId' => $function->getAttribute('scheduleInternalId'),
'repositoryId' => $function->getAttribute('repositoryId'),
'repositoryInternalId' => $function->getAttribute('repositoryInternalId'),
]));
// Backwards compatibility with 1.6 behaviour
$requestFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
@@ -321,12 +326,12 @@ class Create extends Base
referenceType: 'branch'
);
$function = $function
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentId' => $deployment->getId(),
'latestDeploymentInternalId' => $deployment->getSequence(),
'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
} elseif (!$template->isEmpty()) {
// Deploy non-VCS from template
$deploymentId = ID::unique();
@@ -347,12 +352,12 @@ class Create extends Base
'activate' => true,
]));
$function = $function
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('functions', $function->getId(), $function);
$function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
'latestDeploymentId' => $deployment->getId(),
'latestDeploymentInternalId' => $deployment->getSequence(),
'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
$queueForBuilds
->setType(BUILD_TYPE_DEPLOYMENT)
@@ -13,6 +13,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
@@ -90,7 +91,10 @@ class Delete extends Base
$schedule
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('active', false);
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([
'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'),
'active' => $schedule->getAttribute('active'),
])));
}
$queueForDeletes
@@ -103,7 +103,11 @@ class Update extends Base
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $function->getAttribute('schedule'))
->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId')));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([
'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'),
'schedule' => $schedule->getAttribute('schedule'),
'active' => $schedule->getAttribute('active'),
])));
$queries = [
Query::equal('trigger', ['manual']),
@@ -119,7 +123,10 @@ class Update extends Base
->setAttribute('deploymentId', $deployment->getId())
->setAttribute('deploymentInternalId', $deployment->getSequence());
$authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), $rule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
'deploymentId' => $rule->getAttribute('deploymentId'),
'deploymentInternalId' => $rule->getAttribute('deploymentInternalId'),
])));
}, $queries));
$queueForEvents
@@ -105,7 +105,8 @@ class Create extends Base
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
$dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false));
$function->setAttribute('live', false);
$dbForProject->updateDocument('functions', $function->getId(), new Document(['live' => false]));
// Inform scheduler to pull the latest changes
$schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId'));
@@ -113,7 +114,11 @@ class Create extends Base
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $function->getAttribute('schedule'))
->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId')));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([
'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'),
'schedule' => $schedule->getAttribute('schedule'),
'active' => $schedule->getAttribute('active'),
])));
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -11,6 +11,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
@@ -86,7 +87,8 @@ class Delete extends Base
$dbForProject->deleteDocument('variables', $variable->getId());
$dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false));
$function->setAttribute('live', false);
$dbForProject->updateDocument('functions', $function->getId(), new Document(['live' => false]));
// Inform scheduler to pull the latest changes
$schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId'));
@@ -94,7 +96,11 @@ class Delete extends Base
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $function->getAttribute('schedule'))
->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId')));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([
'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'),
'schedule' => $schedule->getAttribute('schedule'),
'active' => $schedule->getAttribute('active'),
])));
$response->noContent();
}
@@ -10,6 +10,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
@@ -99,12 +100,18 @@ class Update extends Base
->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function']));
try {
$dbForProject->updateDocument('variables', $variable->getId(), $variable);
$dbForProject->updateDocument('variables', $variable->getId(), new Document([
'key' => $key,
'value' => $value ?? $variable->getAttribute('value'),
'secret' => $secret ?? $variable->getAttribute('secret'),
'search' => implode(' ', [$variableId, $function->getId(), $key, 'function']),
]));
} catch (DuplicateException $th) {
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
$dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false));
$function->setAttribute('live', false);
$dbForProject->updateDocument('functions', $function->getId(), new Document(['live' => false]));
// Inform scheduler to pull the latest changes
$schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId'));
@@ -112,7 +119,11 @@ class Update extends Base
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $function->getAttribute('schedule'))
->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId')));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([
'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'),
'schedule' => $schedule->getAttribute('schedule'),
'active' => $schedule->getAttribute('active'),
])));
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
@@ -279,7 +279,10 @@ class Builds extends Action
$deployment->setAttribute('buildStartedAt', $startTime);
$deployment->setAttribute('status', 'processing');
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildStartedAt' => $startTime,
'status' => 'processing',
]));
if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) {
$resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')]));
@@ -366,7 +369,11 @@ class Builds extends Action
->setAttribute('sourcePath', $source)
->setAttribute('sourceSize', $directorySize)
->setAttribute('totalSize', $directorySize);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'sourcePath' => $deployment->getAttribute('sourcePath'),
'sourceSize' => $deployment->getAttribute('sourceSize'),
'totalSize' => $deployment->getAttribute('totalSize'),
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
@@ -480,7 +487,13 @@ class Builds extends Action
$deployment->setAttribute('providerCommitAuthor', APP_VCS_GITHUB_USERNAME);
$deployment->setAttribute('providerCommitMessage', "Create '" . $resource->getAttribute('name', '') . "' function");
$deployment->setAttribute('providerCommitUrl', "https://github.com/$cloneOwner/$cloneRepository/commit/$providerCommitHash");
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'providerCommitHash' => $deployment->getAttribute('providerCommitHash'),
'providerCommitAuthorUrl' => $deployment->getAttribute('providerCommitAuthorUrl'),
'providerCommitAuthor' => $deployment->getAttribute('providerCommitAuthor'),
'providerCommitMessage' => $deployment->getAttribute('providerCommitMessage'),
'providerCommitUrl' => $deployment->getAttribute('providerCommitUrl'),
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
@@ -528,7 +541,11 @@ class Builds extends Action
->setAttribute('sourcePath', $source)
->setAttribute('sourceSize', $directorySize)
->setAttribute('totalSize', $directorySize);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'sourcePath' => $deployment->getAttribute('sourcePath'),
'sourceSize' => $deployment->getAttribute('sourceSize'),
'totalSize' => $deployment->getAttribute('totalSize'),
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
@@ -543,7 +560,9 @@ class Builds extends Action
/** Request the executor to build the code... */
$deployment->setAttribute('status', 'building');
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'status' => 'building',
]));
if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) {
$resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')]));
@@ -819,7 +838,9 @@ class Builds extends Action
if ($affected) {
$deployment = $deployment->setAttribute('buildLogs', $currentLogs);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildLogs' => $currentLogs,
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
@@ -905,7 +926,14 @@ class Builds extends Action
}
}
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildPath' => $deployment->getAttribute('buildPath'),
'buildSize' => $deployment->getAttribute('buildSize'),
'totalSize' => $deployment->getAttribute('totalSize'),
'buildLogs' => $deployment->getAttribute('buildLogs'),
'adapter' => $deployment->getAttribute('adapter'),
'fallbackFile' => $deployment->getAttribute('fallbackFile'),
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();
@@ -916,12 +944,15 @@ class Builds extends Action
$logs = $deployment->getAttribute('buildLogs', '');
$date = \date('H:i:s');
$logs .= "[$date] [appwrite] Deployment finished. \n";
$logs .= "\033[90m[$date] \033[90m[\033[0mappwrite\033[90m]\033[32m Deployment finished. \033[0m\n";
$deployment->setAttribute('buildLogs', $logs);
/** Update the status */
$deployment->setAttribute('status', 'ready');
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'buildLogs' => $deployment->getAttribute('buildLogs'),
'status' => 'ready',
]));
Console::log('Status marked as ready');
@@ -1109,7 +1140,11 @@ class Builds extends Action
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $resource->getAttribute('schedule'))
->setAttribute('active', !empty($resource->getAttribute('schedule')) && !empty($resource->getAttribute('deploymentId')));
$dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule);
$dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([
'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'),
'schedule' => $schedule->getAttribute('schedule'),
'active' => $schedule->getAttribute('active'),
]));
}
/** Screenshot site */
@@ -1160,7 +1195,12 @@ class Builds extends Action
$deployment->setAttribute('status', 'failed');
$deployment->setAttribute('buildLogs', $message);
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'buildEndedAt' => $deployment->getAttribute('buildEndedAt'),
'buildDuration' => $deployment->getAttribute('buildDuration'),
'status' => 'failed',
'buildLogs' => $message,
]));
if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) {
$resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')]));
@@ -1467,7 +1507,9 @@ class Builds extends Action
$logs .= "[$date] [appwrite] Git action failed. Deployment will continue. \n";
$deployment->setAttribute('buildLogs', $logs);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildLogs' => $deployment->getAttribute('buildLogs'),
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
@@ -1483,10 +1525,12 @@ class Builds extends Action
$logs = $deployment->getAttribute('buildLogs', '');
$date = \date('H:i:s');
$logs .= "[$date] [appwrite] Build has been canceled. \n";
$logs .= "\033[90m[$date] \033[90m[\033[0mappwrite\033[90m]\033[33m Build has been canceled. \033[0m\n";
$deployment->setAttribute('buildLogs', $logs);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildLogs' => $deployment->getAttribute('buildLogs'),
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
@@ -9,6 +9,7 @@ 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\Datetime as DatetimeValidator;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
@@ -73,7 +74,7 @@ class Update extends Action
->setAttribute('name', $name)
->setAttribute('expire', $expire);
$dbForPlatform->updateDocument('devKeys', $key->getId(), $key);
$dbForPlatform->updateDocument('devKeys', $key->getId(), new Document(['name' => $name, 'expire' => $expire]));
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
@@ -11,6 +11,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator;
@@ -77,9 +78,9 @@ class Update extends Action
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$project->setAttribute('labels', (array) \array_values(\array_unique($labels)));
$labels = (array) \array_values(\array_unique($labels));
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project);
$project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]));
$response->dynamic($project, Response::MODEL_PROJECT);
}
@@ -10,6 +10,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
@@ -72,34 +73,31 @@ class Update extends Action
$permissions = $this->getPermissions($teamId, $projectId);
$project
->setAttribute('teamId', $teamId)
->setAttribute('teamInternalId', $team->getSequence())
->setAttribute('$permissions', $permissions);
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project);
$project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
'teamId' => $teamId,
'teamInternalId' => $team->getSequence(),
'$permissions' => $permissions,
]));
$installations = $dbForPlatform->find('installations', [
Query::equal('projectInternalId', [$project->getSequence()]),
]);
foreach ($installations as $installation) {
$installation->setAttribute('$permissions', $permissions);
$dbForPlatform->updateDocument('installations', $installation->getId(), $installation);
$dbForPlatform->updateDocument('installations', $installation->getId(), new Document(['$permissions' => $permissions]));
}
$repositories = $dbForPlatform->find('repositories', [
Query::equal('projectInternalId', [$project->getSequence()]),
]);
foreach ($repositories as $repository) {
$repository->setAttribute('$permissions', $permissions);
$dbForPlatform->updateDocument('repositories', $repository->getId(), $repository);
$dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['$permissions' => $permissions]));
}
$vcsComments = $dbForPlatform->find('vcsComments', [
Query::equal('projectInternalId', [$project->getSequence()]),
]);
foreach ($vcsComments as $vcsComment) {
$vcsComment->setAttribute('$permissions', $permissions);
$dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), $vcsComment);
$dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), new Document(['$permissions' => $permissions]));
}
$response->dynamic($project, Response::MODEL_PROJECT);
@@ -237,7 +237,7 @@ class Create extends Action
foreach ($activeDeployments as $activeDeployment) {
$activeDeployment->setAttribute('activate', false);
$dbForProject->updateDocument('deployments', $activeDeployment->getId(), $activeDeployment);
$dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document(['activate' => false]));
}
}
@@ -272,7 +272,12 @@ class Create extends Action
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('sites', $site->getId(), $site);
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'latestDeploymentId' => $deployment->getId(),
'latestDeploymentInternalId' => $deployment->getSequence(),
'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
@@ -302,7 +307,10 @@ class Create extends Action
]))
);
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceSize', $fileSize)->setAttribute('sourceMetadata', $metadata));
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceSize' => $fileSize,
'sourceMetadata' => $metadata,
]));
}
// Start the build
@@ -342,7 +350,12 @@ class Create extends Action
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('sites', $site->getId(), $site);
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'latestDeploymentId' => $site->getAttribute('latestDeploymentId'),
'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'),
'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'),
'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'),
]));
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
@@ -368,7 +381,10 @@ class Create extends Action
]))
);
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceChunksUploaded', $chunksUploaded)->setAttribute('sourceMetadata', $metadata));
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceChunksUploaded' => $chunksUploaded,
'sourceMetadata' => $metadata,
]));
}
}
@@ -107,11 +107,12 @@ class Delete extends Action
$site = $dbForProject->updateDocument(
'sites',
$site->getId(),
$site
->setAttribute('latestDeploymentCreatedAt', $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt())
->setAttribute('latestDeploymentInternalId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence())
->setAttribute('latestDeploymentId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getId())
->setAttribute('latestDeploymentStatus', $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', ''))
new Document([
'latestDeploymentCreatedAt' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt(),
'latestDeploymentInternalId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence(),
'latestDeploymentId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getId(),
'latestDeploymentStatus' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', ''),
])
);
}
@@ -142,7 +142,12 @@ class Create extends Action
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('sites', $site->getId(), $site);
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'latestDeploymentId' => $site->getAttribute('latestDeploymentId'),
'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'),
'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'),
'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'),
]));
// Preview deployments for sites
$sitesDomain = $platform['sitesDomain'];
@@ -89,7 +89,7 @@ class Update extends Action
$endTime = new \DateTime('now');
$duration = $endTime->getTimestamp() - $startTime->getTimestamp();
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment->setAttributes([
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildEndedAt' => DateTime::now(),
'buildDuration' => $duration,
'status' => 'canceled'
@@ -97,7 +97,9 @@ class Update extends Action
if ($deployment->getSequence() === $site->getAttribute('latestDeploymentInternalId', '')) {
$site = $site->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('sites', $site->getId(), $site);
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'),
]));
}
try {
@@ -187,7 +187,12 @@ class Create extends Base
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$dbForProject->updateDocument('sites', $site->getId(), $site);
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'latestDeploymentId' => $site->getAttribute('latestDeploymentId'),
'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'),
'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'),
'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'),
]));
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
@@ -71,6 +71,16 @@ class Get extends Base
throw new Exception(Exception::LOG_NOT_FOUND);
}
// Override status in response if the log is stuck in waiting/processing beyond the site timeout.
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $site->getAttribute('timeout', 30);
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
$response->dynamic($log, Response::MODEL_EXECUTION); //TODO: Change to model log, but model log already exists - decide what to do
}
}
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Database\Validator\Queries\Logs;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -99,6 +100,35 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing log is considered timed out.
$timeout = $site->getAttribute('timeout', 30);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing logs created before the timeout threshold, so timed-out
// logs that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -108,6 +138,20 @@ class XList extends Base
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.");
}
// Override status in response for timed-out logs, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $log) {
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -136,7 +137,7 @@ class Create extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
$site = $dbForProject->createDocument('sites', new Document([
$site = new Document([
'$id' => $siteId,
'enabled' => $enabled,
'live' => true,
@@ -166,13 +167,17 @@ class Create extends Base
'runtimeSpecification' => $specification,
'buildRuntime' => $buildRuntime,
'adapter' => $adapter,
]));
]);
try {
$site = $dbForProject->createDocument('sites', $site);
} catch (DuplicateException) {
throw new Exception(Exception::SITE_ALREADY_EXISTS);
}
// Git connect logic
if (!empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = $dbForPlatform->createDocument('repositories', new Document([
$repository = new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -184,13 +189,16 @@ class Create extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]));
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
$site->setAttribute('repositoryId', $repository->getId());
$site->setAttribute('repositoryInternalId', $repository->getSequence());
}
$site = $dbForProject->updateDocument('sites', $site->getId(), $site);
$site = $dbForProject->updateDocument('sites', $site->getId(), new Document([
'repositoryId' => $repository->getId(),
'repositoryInternalId' => $repository->getSequence(),
]));
}
$queueForEvents->setParam('siteId', $site->getId());
@@ -111,7 +111,10 @@ class Update extends Base
->setAttribute('deploymentId', $deployment->getId())
->setAttribute('deploymentInternalId', $deployment->getSequence());
$authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), $rule));
$authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
'deploymentId' => $rule->getAttribute('deploymentId'),
'deploymentInternalId' => $rule->getAttribute('deploymentInternalId'),
])));
}, $queries));
$queueForEvents
@@ -190,11 +190,9 @@ class Update extends Base
$repositoryInternalId = '';
}
// Git connect logic
if (!$isConnected && !empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = $dbForPlatform->createDocument('repositories', new Document([
$repository = new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -206,8 +204,8 @@ class Update extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]));
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
}
@@ -92,7 +92,9 @@ class Create extends Base
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
$dbForProject->updateDocument('sites', $site->getId(), $site->setAttribute('live', false));
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'live' => false,
]));
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -10,6 +10,7 @@ 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;
@@ -76,7 +77,9 @@ class Delete extends Base
$dbForProject->deleteDocument('variables', $variable->getId());
$dbForProject->updateDocument('sites', $site->getId(), $site->setAttribute('live', false));
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'live' => false,
]));
$response->noContent();
}
@@ -9,6 +9,7 @@ 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\Exception\Duplicate as DuplicateException;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
@@ -93,12 +94,19 @@ class Update extends Base
->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site']));
try {
$dbForProject->updateDocument('variables', $variable->getId(), $variable);
$dbForProject->updateDocument('variables', $variable->getId(), new Document([
'key' => $variable->getAttribute('key'),
'value' => $variable->getAttribute('value'),
'secret' => $variable->getAttribute('secret'),
'search' => $variable->getAttribute('search'),
]));
} catch (DuplicateException $th) {
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
$dbForProject->updateDocument('sites', $site->getId(), $site->setAttribute('live', false));
$dbForProject->updateDocument('sites', $site->getId(), new Document([
'live' => false,
]));
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
@@ -0,0 +1,137 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Logs;
use Appwrite\Detector\Detector;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use MaxMind\Db\Reader;
use Utopia\Audit\Audit;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Queries;
use Utopia\Database\Validator\Query\Limit;
use Utopia\Database\Validator\Query\Offset;
use Utopia\Database\Validator\UID;
use Utopia\Locale\Locale;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listTeamLogs';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/logs')
->desc('List team logs')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'logs',
name: 'listLogs',
description: '/docs/references/teams/get-team-logs.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_LOG_LIST,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->inject('locale')
->inject('geodb')
->inject('audit')
->callback($this->action(...));
}
public function action(string $teamId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Audit $audit)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$grouped = Query::groupByType($queries);
$limit = $grouped['limit'] ?? 25;
$offset = $grouped['offset'] ?? 0;
$resource = 'team/' . $team->getId();
$logs = $audit->getLogsByResource($resource, offset: $offset, limit: $limit);
$output = [];
foreach ($logs as $i => &$log) {
$log['userAgent'] = (!empty($log['userAgent'])) ? $log['userAgent'] : 'UNKNOWN';
$detector = new Detector($log['userAgent']);
$detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then)
$os = $detector->getOS();
$client = $detector->getClient();
$device = $detector->getDevice();
$output[$i] = new Document([
'event' => $log['event'],
'userId' => $log['data']['userId'],
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
'osName' => $os['osName'],
'osVersion' => $os['osVersion'],
'clientType' => $client['clientType'],
'clientCode' => $client['clientCode'],
'clientName' => $client['clientName'],
'clientVersion' => $client['clientVersion'],
'clientEngine' => $client['clientEngine'],
'clientEngineVersion' => $client['clientEngineVersion'],
'deviceName' => $device['deviceName'],
'deviceBrand' => $device['deviceBrand'],
'deviceModel' => $device['deviceModel']
]);
$record = $geodb->get($log['ip']);
if ($record) {
$output[$i]['countryCode'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), false) ? \strtolower($record['country']['iso_code']) : '--';
$output[$i]['countryName'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), $locale->getText('locale.country.unknown'));
} else {
$output[$i]['countryCode'] = '--';
$output[$i]['countryName'] = $locale->getText('locale.country.unknown');
}
}
$response->dynamic(new Document([
'total' => $includeTotal ? $audit->countLogsByResource($resource) : 0,
'logs' => $output,
]), Response::MODEL_LOG_LIST);
}
}
@@ -0,0 +1,439 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\Network\Validator\Email as EmailValidator;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberUtil;
use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Key;
use Utopia\Database\Validator\UID;
use Utopia\Emails\Email;
use Utopia\Locale\Locale;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/teams/:teamId/memberships')
->desc('Create team membership')
->groups(['api', 'teams', 'auth'])
->label('event', 'teams.[teamId].memberships.[membershipId].create')
->label('scope', 'teams.write')
->label('auth.type', 'invites')
->label('audits.event', 'membership.create')
->label('audits.resource', 'team/{request.teamId}')
->label('audits.userId', '{request.userId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'createMembership',
description: '/docs/references/teams/create-team-membership.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->label('abuse-limit', 10)
->param('teamId', '', new UID(), 'Team ID.')
->param('email', '', new EmailValidator(), 'Email of the new team member.', true)
->param('userId', '', new UID(), 'ID of the user to be added to a team.', true)
->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
->param('roles', [], new ArrayList(new Key(maxLength: 81), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 81 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // TODO add our own built-in confirm page
->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true)
->inject('response')
->inject('project')
->inject('user')
->inject('dbForProject')
->inject('authorization')
->inject('locale')
->inject('queueForMails')
->inject('queueForMessaging')
->inject('queueForEvents')
->inject('timelimit')
->inject('queueForStatsUsage')
->inject('plan')
->inject('proofForPassword')
->inject('proofForToken')
->callback($this->action(...));
}
public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Password $proofForPassword, Token $proofForToken)
{
$isAppUser = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
if (empty($url)) {
if (!$isAppUser && !$isPrivilegedUser) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required');
}
}
if (empty($userId) && empty($email) && empty($phone)) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'At least one of userId, email, or phone is required');
}
if (!$isPrivilegedUser && !$isAppUser && empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED);
}
$email = \strtolower($email);
$name = empty($name) ? $email : $name;
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if (!empty($userId)) {
$invitee = $dbForProject->getDocument('users', $userId);
if ($invitee->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND, 'User with given userId doesn\'t exist.', 404);
}
if (!empty($email) && $invitee->getAttribute('email', '') !== $email) {
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and email doesn\'t match', 409);
}
if (!empty($phone) && $invitee->getAttribute('phone', '') !== $phone) {
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and phone doesn\'t match', 409);
}
$email = $invitee->getAttribute('email', '');
$phone = $invitee->getAttribute('phone', '');
$name = $invitee->getAttribute('name', '') ?: $name;
} elseif (!empty($email)) {
$invitee = $dbForProject->findOne('users', [Query::equal('email', [$email])]); // Get user by email address
if (!$invitee->isEmpty() && !empty($phone) && $invitee->getAttribute('phone', '') !== $phone) {
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given email and phone doesn\'t match', 409);
}
} elseif (!empty($phone)) {
$invitee = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]);
if (!$invitee->isEmpty() && !empty($email) && $invitee->getAttribute('email', '') !== $email) {
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given phone and email doesn\'t match', 409);
}
}
if ($invitee->isEmpty()) { // Create new user if no user with same email found
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
if (!$isPrivilegedUser && !$isAppUser && $limit !== 0 && $project->getId() !== 'console') { // check users limit, console invites are allways allowed.
$total = $dbForProject->count('users', [], APP_LIMIT_USERS);
if ($total >= $limit) {
throw new Exception(Exception::USER_COUNT_EXCEEDED, 'Project registration is restricted. Contact your administrator for more information.');
}
}
// Makes sure this email is not already used in another identity
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
Query::equal('providerEmail', [$email]),
]);
if (!$identityWithMatchingEmail->isEmpty()) {
throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS);
}
try {
$userId = ID::unique();
$hash = $proofForPassword->hash($proofForPassword->generate());
$emailCanonical = new Email($email);
} catch (Throwable) {
$emailCanonical = null;
}
$userId = ID::unique();
$userDocument = new Document([
'$id' => $userId,
'$permissions' => [
Permission::read(Role::any()),
Permission::read(Role::user($userId)),
Permission::update(Role::user($userId)),
Permission::delete(Role::user($userId)),
],
'email' => empty($email) ? null : $email,
'phone' => empty($phone) ? null : $phone,
'emailVerification' => false,
'status' => true,
// TODO: Set password empty?
'password' => $hash,
'hash' => $proofForPassword->getHash()->getName(),
'hashOptions' => $proofForPassword->getHash()->getOptions(),
/**
* Set the password update time to 0 for users created using
* team invite and OAuth to allow password updates without an
* old password
*/
'passwordUpdate' => null,
'registration' => DateTime::now(),
'reset' => false,
'name' => $name,
'prefs' => new \stdClass(),
'sessions' => null,
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email, $name]),
'emailCanonical' => $emailCanonical?->getCanonical(),
'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
'emailIsCorporate' => $emailCanonical?->isCorporate(),
'emailIsDisposable' => $emailCanonical?->isDisposable(),
'emailIsFree' => $emailCanonical?->isFree(),
]);
try {
$invitee = $authorization->skip(fn () => $dbForProject->createDocument('users', $userDocument));
} catch (Duplicate $th) {
throw new Exception(Exception::USER_ALREADY_EXISTS);
}
}
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
if (!$isOwner && !$isPrivilegedUser && !$isAppUser) { // Not owner, not admin, not app (server)
throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not allowed to send invitations for this team');
}
$membership = $dbForProject->findOne('memberships', [
Query::equal('userInternalId', [$invitee->getSequence()]),
Query::equal('teamInternalId', [$team->getSequence()]),
]);
$secret = $proofForToken->generate();
if ($membership->isEmpty()) {
$membershipId = ID::unique();
$membership = new Document([
'$id' => $membershipId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::user($invitee->getId())),
Permission::update(Role::team($team->getId(), 'owner')),
Permission::delete(Role::user($invitee->getId())),
Permission::delete(Role::team($team->getId(), 'owner')),
],
'userId' => $invitee->getId(),
'userInternalId' => $invitee->getSequence(),
'teamId' => $team->getId(),
'teamInternalId' => $team->getSequence(),
'roles' => $roles,
'invited' => DateTime::now(),
'joined' => ($isPrivilegedUser || $isAppUser) ? DateTime::now() : null,
'confirm' => ($isPrivilegedUser || $isAppUser),
'secret' => $proofForToken->hash($secret),
'search' => implode(' ', [$membershipId, $invitee->getId()])
]);
$membership = ($isPrivilegedUser || $isAppUser) ?
$authorization->skip(fn () => $dbForProject->createDocument('memberships', $membership)) :
$dbForProject->createDocument('memberships', $membership);
if ($isPrivilegedUser || $isAppUser) {
$authorization->skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1));
}
} elseif ($membership->getAttribute('confirm') === false) {
$secretHash = $proofForToken->hash($secret);
$invitedTime = DateTime::now();
if ($isPrivilegedUser || $isAppUser) {
$membership = $authorization->skip(fn () => $dbForProject->updateDocument('memberships', $membership->getId(), new Document([
'secret' => $secretHash,
'invited' => $invitedTime,
'joined' => DateTime::now(),
'confirm' => true
])));
} else {
$membership = $dbForProject->updateDocument('memberships', $membership->getId(), new Document([
'secret' => $secretHash,
'invited' => $invitedTime
]));
}
} else {
throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED);
}
if ($isPrivilegedUser || $isAppUser) {
$dbForProject->purgeCachedDocument('users', $invitee->getId());
} else {
$url = Template::parseURL($url);
$url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['membershipId' => $membership->getId(), 'userId' => $invitee->getId(), 'secret' => $secret, 'teamId' => $teamId, 'teamName' => $team->getAttribute('name')]);
$url = Template::unParseURL($url);
if (!empty($email)) {
$projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]');
$body = $locale->getText("emails.invitation.body");
$preview = $locale->getText("emails.invitation.preview");
$subject = $locale->getText("emails.invitation.subject");
$customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? [];
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl');
$message
->setParam('{{body}}', $body, escapeHtml: false)
->setParam('{{hello}}', $locale->getText("emails.invitation.hello"))
->setParam('{{footer}}', $locale->getText("emails.invitation.footer"))
->setParam('{{thanks}}', $locale->getText("emails.invitation.thanks"))
->setParam('{{buttonText}}', $locale->getText("emails.invitation.buttonText"))
->setParam('{{signature}}', $locale->getText("emails.invitation.signature"));
$body = $message->render();
$smtp = $project->getAttribute('smtp', []);
$smtpEnabled = $smtp['enabled'] ?? false;
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
$senderEmail = $smtp['senderEmail'];
}
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
}
$queueForMails
->setSmtpHost($smtp['host'] ?? '')
->setSmtpPort($smtp['port'] ?? '')
->setSmtpUsername($smtp['username'] ?? '')
->setSmtpPassword($smtp['password'] ?? '')
->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
}
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
if (!empty($customTemplate['replyTo'])) {
$replyTo = $customTemplate['replyTo'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
$emailVariables = [
'owner' => $user->getAttribute('name'),
'direction' => $locale->getText('settings.direction'),
/* {{user}}, {{team}}, {{redirect}} and {{project}} are required in default and custom templates */
'user' => $name,
'team' => $team->getAttribute('name'),
'redirect' => $url,
'project' => $projectName
];
$queueForMails
->setSubject($subject)
->setBody($body)
->setPreview($preview)
->setRecipient($invitee->getAttribute('email'))
->setName($invitee->getAttribute('name', ''))
->appendVariables($emailVariables)
->trigger();
} elseif (!empty($phone)) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'];
}
$message = $message->setParam('{{token}}', $url);
$message = $message->render();
$messageDoc = new Document([
'$id' => ID::unique(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$phone])
->setProviderType('SMS');
$helper = PhoneNumberUtil::getInstance();
try {
$countryCode = $helper->parse($phone)->getCountryCode();
if (!empty($countryCode)) {
$queueForStatsUsage
->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
}
} catch (NumberParseException $e) {
// Ignore invalid phone number for country code stats
}
$queueForStatsUsage
->addMetric(METRIC_AUTH_METHOD_PHONE, 1)
->setProject($project)
->trigger();
}
}
$queueForEvents
->setParam('userId', $invitee->getId())
->setParam('teamId', $team->getId())
->setParam('membershipId', $membership->getId())
;
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic(
$membership
->setAttribute('teamName', $team->getAttribute('name'))
->setAttribute('userName', $invitee->getAttribute('name'))
->setAttribute('userEmail', $invitee->getAttribute('email')),
Response::MODEL_MEMBERSHIP
);
}
}
@@ -0,0 +1,158 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
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\Exception\Authorization as AuthorizationException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
->desc('Delete team membership')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].memberships.[membershipId].delete')
->label('scope', 'teams.write')
->label('audits.event', 'membership.delete')
->label('audits.resource', 'team/{request.teamId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'deleteMembership',
description: '/docs/references/teams/delete-team-membership.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->inject('user')
->inject('project')
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, Document $user, Document $project, Response $response, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty()) {
throw new Exception(Exception::TEAM_INVITE_NOT_FOUND);
}
$profile = $dbForProject->getDocument('users', $membership->getAttribute('userId'));
if ($profile->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if ($membership->getAttribute('teamInternalId') !== $team->getSequence()) {
throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH);
}
$this->validate($profile, $team, $dbForProject);
if ($project->getId() === 'console') {
// Quick check:
// fetch up to 2 owners to determine if only one exists
$ownersCount = $dbForProject->count(
collection: 'memberships',
queries: [
Query::contains('roles', ['owner']),
Query::equal('teamInternalId', [$team->getSequence()])
],
max: 2
);
// Is the deletion being requested by the user on their own membership and they are also the owner?
$isSelfOwner =
in_array('owner', $membership->getAttribute('roles')) &&
$membership->getAttribute('userInternalId') === $user->getSequence();
if ($ownersCount === 1 && $isSelfOwner) {
/**
* Prevent removal if the user is the only owner, this is because -
*
* 1. Other roles [if exists] can neither add a new owner nor delete the organization.
* 2. If the only owner is removed, while there were no other members, the organization isn't marked for deletion and stays in a limbo.
*/
throw new Exception(Exception::MEMBERSHIP_DELETION_PROHIBITED, 'There must be at least one owner in the organization.');
}
}
try {
$dbForProject->deleteDocument('memberships', $membership->getId());
} catch (AuthorizationException $exception) {
throw new Exception(Exception::USER_UNAUTHORIZED);
}
$dbForProject->purgeCachedDocument('users', $profile->getId());
// This membership is primary for the team, update the primary to next member.
if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) {
$membership = $dbForProject->findOne('memberships', [
Query::equal('teamInternalId', [$team->getSequence()]),
]);
if (!$membership->isEmpty()) {
$team->setAttribute('userId', $membership->getAttribute('userId'));
$team->setAttribute('userInternalId', $membership->getAttribute('userInternalId'));
$dbForProject->updateDocument('teams', $team->getId(), new Document([
'userId' => $membership->getAttribute('userId'),
'userInternalId' => $membership->getAttribute('userInternalId'),
]));
}
}
if ($membership->getAttribute('confirm')) { // Count only confirmed members
$authorization->skip(fn () => $dbForProject->decreaseDocumentAttribute('teams', $team->getId(), 'total', 1, 0));
}
$queueForEvents
->setParam('teamId', $team->getId())
->setParam('userId', $profile->getId())
->setParam('membershipId', $membership->getId())
->setPayload($response->output($membership, Response::MODEL_MEMBERSHIP));
$response->noContent();
}
protected function validate(Document $profile, Document $team, Database $dbForProject): void
{
return;
}
}
@@ -0,0 +1,119 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
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\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
->desc('Get team membership')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'getMembership',
description: '/docs/references/teams/get-team-member.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty() || empty($membership->getAttribute('userId'))) {
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
];
$roles = $authorization->getRoles();
$isPrivilegedUser = User::isPrivileged($roles);
$isAppUser = User::isApp($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
}, $membershipsPrivacy);
$user = !empty(array_filter($membershipsPrivacy))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
$mfa = $user->getAttribute('mfa', false);
if ($mfa) {
$totp = TOTP::getAuthenticatorFromUser($user);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
}
}
$membership->setAttribute('mfa', $mfa);
}
if ($membershipsPrivacy['userName']) {
$membership->setAttribute('userName', $user->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
$membership->setAttribute('userEmail', $user->getAttribute('email'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
$response->dynamic($membership, Response::MODEL_MEMBERSHIP);
}
}
@@ -0,0 +1,212 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships\Status;
use Appwrite\Detector\Detector;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use MaxMind\Db\Reader;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamMembershipStatus';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId/status')
->desc('Update team membership status')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].memberships.[membershipId].update.status')
->label('scope', 'public')
->label('audits.event', 'membership.update')
->label('audits.resource', 'team/{request.teamId}')
->label('audits.userId', '{request.userId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'updateMembershipStatus',
description: '/docs/references/teams/update-team-membership-status.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->param('userId', '', new UID(), 'User ID.')
->param('secret', '', new Text(256), 'Secret key.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('authorization')
->inject('project')
->inject('geodb')
->inject('queueForEvents')
->inject('store')
->inject('proofForToken')
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken)
{
$protocol = $request->getProtocol();
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty()) {
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
$team = $authorization->skip(fn () => $dbForProject->getDocument('teams', $teamId));
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if ($membership->getAttribute('teamInternalId') !== $team->getSequence()) {
throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH);
}
if (!$proofForToken->verify($secret, $membership->getAttribute('secret'))) {
throw new Exception(Exception::TEAM_INVALID_SECRET);
}
if ($userId !== $membership->getAttribute('userId')) {
throw new Exception(Exception::TEAM_INVITE_MISMATCH, 'Invite does not belong to current user (' . $user->getAttribute('email') . ')');
}
$hasSession = !$user->isEmpty();
if (!$hasSession) {
$user->setAttributes($dbForProject->getDocument('users', $userId)->getArrayCopy()); // Get user
}
if ($membership->getAttribute('userInternalId') !== $user->getSequence()) {
throw new Exception(Exception::TEAM_INVITE_MISMATCH, 'Invite does not belong to current user (' . $user->getAttribute('email') . ')');
}
if ($membership->getAttribute('confirm') === true) {
throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED);
}
$membership // Attach user to team
->setAttribute('joined', DateTime::now())
->setAttribute('confirm', true)
;
$authorization->skip(fn () => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true)));
// Create session for the user if not logged in
if (!$hasSession) {
$authorization->addRole(Role::user($user->getId())->toString());
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::addSeconds(new \DateTime(), $authDuration);
$secret = $proofForToken->generate();
$session = new Document(array_merge([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
],
'userId' => $user->getId(),
'userInternalId' => $user->getSequence(),
'provider' => SESSION_PROVIDER_EMAIL,
'providerUid' => $user->getAttribute('email'),
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $authDuration)
], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
$session = $dbForProject->createDocument('sessions', $session);
$authorization->addRole(Role::user($userId)->toString());
$encoded = $store
->setProperty('id', $user->getId())
->setProperty('secret', $secret)
->encode();
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$response
->addCookie(
name: $store->getKey() . '_legacy',
value: $encoded,
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
domain: Config::getParam('cookieDomain'),
secure: ('https' === $protocol),
httponly: true
)
->addCookie(
name: $store->getKey(),
value: $encoded,
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
domain: Config::getParam('cookieDomain'),
secure: ('https' === $protocol),
httponly: true,
sameSite: Config::getParam('cookieSamesite')
)
;
}
$membership = $dbForProject->updateDocument('memberships', $membership->getId(), new Document(['joined' => $membership->getAttribute('joined'), 'confirm' => true]));
$dbForProject->purgeCachedDocument('users', $user->getId());
$authorization->skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1));
$queueForEvents
->setParam('userId', $user->getId())
->setParam('teamId', $team->getId())
->setParam('membershipId', $membership->getId())
;
$response->dynamic(
$membership
->setAttribute('teamName', $team->getAttribute('name'))
->setAttribute('userName', $user->getAttribute('name'))
->setAttribute('userEmail', $user->getAttribute('email')),
Response::MODEL_MEMBERSHIP
);
}
}
@@ -0,0 +1,139 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Key;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamMembership';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
->desc('Update team membership')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].memberships.[membershipId].update')
->label('scope', 'teams.write')
->label('audits.event', 'membership.update')
->label('audits.resource', 'team/{request.teamId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'updateMembership',
description: '/docs/references/teams/update-team-membership.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->param('roles', [], new ArrayList(new Key(maxLength: 81), APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 81 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
->inject('request')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('authorization')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$membership = $dbForProject->getDocument('memberships', $membershipId);
if ($membership->isEmpty()) {
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
$profile = $dbForProject->getDocument('users', $membership->getAttribute('userId'));
if ($profile->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = User::isApp($authorization->getRoles());
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
if ($project->getId() === 'console') {
// Quick check: fetch up to 2 owners to determine if only one exists
$ownersCount = $dbForProject->count(
collection: 'memberships',
queries: [
Query::contains('roles', ['owner']),
Query::equal('teamInternalId', [$team->getSequence()])
],
max: 2
);
// Is the role change being requested by the user on their own membership?
$isCurrentUserAnOwner = $user->getSequence() === $membership->getAttribute('userInternalId');
// Prevent role change if there's only one owner left,
// the requester is that owner, and the new `$roles` no longer include 'owner'
if ($ownersCount === 1 && $isOwner && $isCurrentUserAnOwner && !\in_array('owner', $roles)) {
throw new Exception(Exception::MEMBERSHIP_DOWNGRADE_PROHIBITED, 'There must be at least one owner in the organization.');
}
}
if (!$isOwner && !$isPrivilegedUser && !$isAppUser) { // Not owner, not admin, not app (server)
throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not allowed to modify roles');
}
/**
* Update the roles
*/
$membership->setAttribute('roles', $roles);
$membership = $dbForProject->updateDocument('memberships', $membership->getId(), new Document(['roles' => $roles]));
/**
* Replace membership on profile
*/
$dbForProject->purgeCachedDocument('users', $profile->getId());
$queueForEvents
->setParam('userId', $profile->getId())
->setParam('teamId', $team->getId())
->setParam('membershipId', $membership->getId());
$response->dynamic(
$membership
->setAttribute('teamName', $team->getAttribute('name'))
->setAttribute('userName', $profile->getAttribute('name'))
->setAttribute('userEmail', $profile->getAttribute('email')),
Response::MODEL_MEMBERSHIP
);
}
}
@@ -0,0 +1,179 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\Memberships;
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\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listTeamMemberships';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/memberships')
->desc('List team memberships')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'memberships',
name: 'listMemberships',
description: '/docs/references/teams/list-team-members.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MEMBERSHIP_LIST,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('queries', [], new Memberships(), '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(', ', Memberships::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
// Set internal queries
$queries[] = Query::equal('teamInternalId', [$team->getSequence()]);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$membershipId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('memberships', $membershipId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Membership '{$membershipId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$memberships = $dbForProject->find(
collection: 'memberships',
queries: $queries,
);
$total = $includeTotal ? $dbForProject->count(
collection: 'memberships',
queries: $filterQueries,
max: APP_LIMIT_COUNT
) : 0;
} 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.");
}
$memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId')));
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
];
$roles = $authorization->getRoles();
$isPrivilegedUser = User::isPrivileged($roles);
$isAppUser = User::isApp($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
}, $membershipsPrivacy);
$memberships = array_map(function ($membership) use ($dbForProject, $team, $membershipsPrivacy) {
$user = !empty(array_filter($membershipsPrivacy))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
$mfa = $user->getAttribute('mfa', false);
if ($mfa) {
$totp = TOTP::getAuthenticatorFromUser($user);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
}
}
$membership->setAttribute('mfa', $mfa);
}
if ($membershipsPrivacy['userName']) {
$membership->setAttribute('userName', $user->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
$membership->setAttribute('userEmail', $user->getAttribute('email'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
return $membership;
}, $memberships);
$response->dynamic(new Document([
'memberships' => $memberships,
'total' => $total,
]), Response::MODEL_MEMBERSHIP_LIST);
}
}
@@ -0,0 +1,71 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Preferences;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
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\Exception\Structure as StructureException;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getTeamPreferences';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId/prefs')
->desc('Get team preferences')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'getPrefs',
description: '/docs/references/teams/get-team-prefs.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PREFERENCES,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(string $teamId, Response $response, Database $dbForProject)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$prefs = $team->getAttribute('prefs', []);
try {
$prefs = new Document($prefs);
} catch (StructureException $e) {
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage());
}
$response->dynamic($prefs, Response::MODEL_PREFERENCES);
}
}
@@ -0,0 +1,83 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Preferences;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
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\Exception\Structure as StructureException;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Assoc;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamPreferences';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/teams/:teamId/prefs')
->desc('Update team preferences')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].update.prefs')
->label('scope', 'teams.write')
->label('audits.event', 'team.update')
->label('audits.resource', 'team/{response.$id}')
->label('audits.userId', '{response.$id}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'updatePrefs',
description: '/docs/references/teams/update-team-prefs.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PREFERENCES,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('prefs', '', new Assoc(), 'Prefs key-value JSON object.')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, array $prefs, Response $response, Database $dbForProject, Event $queueForEvents)
{
try {
$prefs = new Document($prefs);
} catch (StructureException $e) {
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage());
}
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$team = $dbForProject->updateDocument('teams', $team->getId(), new Document([
'prefs' => $prefs->getArrayCopy()
]));
$queueForEvents->setParam('teamId', $team->getId());
$response->dynamic($prefs, Response::MODEL_PREFERENCES);
}
}
@@ -0,0 +1,138 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Key;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createTeam';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/teams')
->desc('Create team')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].create')
->label('scope', 'teams.write')
->label('audits.event', 'team.create')
->label('audits.resource', 'team/{response.$id}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'create',
description: '/docs/references/teams/create-team.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_TEAM,
)
]
))
->param('teamId', '', new CustomId(), 'Team ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
->param('name', null, new Text(128), 'Team name. Max length: 128 chars.')
->param('roles', ['owner'], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the roles in the team for the user who created it. The default role is **owner**. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', true)
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('authorization')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = User::isApp($authorization->getRoles());
$teamId = $teamId == 'unique()' ? ID::unique() : $teamId;
try {
$team = $authorization->skip(fn () => $dbForProject->createDocument('teams', new Document([
'$id' => $teamId,
'$permissions' => [
Permission::read(Role::team($teamId)),
Permission::update(Role::team($teamId, 'owner')),
Permission::delete(Role::team($teamId, 'owner')),
],
'labels' => [],
'name' => $name,
'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1,
'prefs' => new \stdClass(),
'search' => implode(' ', [$teamId, $name]),
])));
} catch (Duplicate $th) {
throw new Exception(Exception::TEAM_ALREADY_EXISTS);
}
if (!$isPrivilegedUser && !$isAppUser) { // Don't add user on server mode
if (!\in_array('owner', $roles)) {
$roles[] = 'owner';
}
$membershipId = ID::unique();
$membership = new Document([
'$id' => $membershipId,
'$permissions' => [
Permission::read(Role::user($user->getId())),
Permission::read(Role::team($team->getId())),
Permission::update(Role::user($user->getId())),
Permission::update(Role::team($team->getId(), 'owner')),
Permission::delete(Role::user($user->getId())),
Permission::delete(Role::team($team->getId(), 'owner')),
],
'userId' => $user->getId(),
'userInternalId' => $user->getSequence(),
'teamId' => $team->getId(),
'teamInternalId' => $team->getSequence(),
'roles' => $roles,
'invited' => DateTime::now(),
'joined' => DateTime::now(),
'confirm' => true,
'secret' => '',
'search' => implode(' ', [$membershipId, $user->getId()])
]);
$membership = $dbForProject->createDocument('memberships', $membership);
$dbForProject->purgeCachedDocument('users', $user->getId());
}
$queueForEvents->setParam('teamId', $team->getId());
if (!empty($user->getId())) {
$queueForEvents->setParam('userId', $user->getId());
}
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($team, Response::MODEL_TEAM);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Workers\Deletes;
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\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteTeam';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/teams/:teamId')
->desc('Delete team')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].delete')
->label('scope', 'teams.write')
->label('audits.event', 'team.delete')
->label('audits.resource', 'team/{request.teamId}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'delete',
description: '/docs/references/teams/delete-team.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('teamId', '', new UID(), 'Team ID.')
->inject('response')
->inject('getProjectDB')
->inject('dbForProject')
->inject('queueForDeletes')
->inject('queueForEvents')
->inject('project')
->callback($this->action(...));
}
public function action(string $teamId, Response $response, callable $getProjectDB, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Document $project)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
if (!$dbForProject->deleteDocument('teams', $teamId)) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove team from DB');
}
// Sync delete
$deletes = new Deletes();
$deletes->deleteMemberships($getProjectDB, $team, $project);
// Async delete
if ($project->getId() === 'console') {
$queueForDeletes
->setType(DELETE_TYPE_TEAM_PROJECTS)
->setDocument($team)
->trigger();
}
$queueForDeletes
->setType(DELETE_TYPE_DOCUMENT)
->setDocument($team);
$queueForEvents
->setParam('teamId', $team->getId())
->setPayload($response->output($team, Response::MODEL_TEAM))
;
$response->noContent();
}
}
@@ -0,0 +1,61 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
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\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getTeam';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams/:teamId')
->desc('Get team')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'get',
description: '/docs/references/teams/get-team.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_TEAM,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(string $teamId, Response $response, Database $dbForProject)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$response->dynamic($team, Response::MODEL_TEAM);
}
}
@@ -0,0 +1,77 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams\Name;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
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\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateTeamName';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/teams/:teamId')
->desc('Update name')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].update')
->label('scope', 'teams.write')
->label('audits.event', 'team.update')
->label('audits.resource', 'team/{response.$id}')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'updateName',
description: '/docs/references/teams/update-team-name.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_TEAM,
)
]
))
->param('teamId', '', new UID(), 'Team ID.')
->param('name', null, new Text(128), 'New team name. Max length: 128 chars.')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $teamId, string $name, Response $response, Database $dbForProject, Event $queueForEvents)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
$team
->setAttribute('name', $name)
->setAttribute('search', implode(' ', [$teamId, $name]));
$team = $dbForProject->updateDocument('teams', $team->getId(), new Document(['name' => $name, 'search' => implode(' ', [$teamId, $name])]));
$queueForEvents->setParam('teamId', $team->getId());
$response->dynamic($team, Response::MODEL_TEAM);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Teams;
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\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listTeams';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/teams')
->desc('List teams')
->groups(['api', 'teams'])
->label('scope', 'teams.read')
->label('sdk', new Method(
namespace: 'teams',
group: 'teams',
name: 'list',
description: '/docs/references/teams/list-teams.md',
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_TEAM_LIST,
)
]
))
->param('queries', [], new Teams(), '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(', ', Teams::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject)
{
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$teamId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('teams', $teamId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Team '{$teamId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$results = $dbForProject->find('teams', $queries);
$total = $includeTotal ? $dbForProject->count('teams', $filterQueries, APP_LIMIT_COUNT) : 0;
} 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([
'teams' => $results,
'total' => $total,
]), Response::MODEL_TEAM_LIST);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Teams;
use Appwrite\Platform\Modules\Teams\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,49 @@
<?php
namespace Appwrite\Platform\Modules\Teams\Services;
use Appwrite\Platform\Modules\Teams\Http\Logs\XList as ListLogs;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Create as CreateMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Delete as DeleteMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Get as GetMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Status\Update as UpdateMembershipStatus;
use Appwrite\Platform\Modules\Teams\Http\Memberships\Update as UpdateMembership;
use Appwrite\Platform\Modules\Teams\Http\Memberships\XList as ListMemberships;
use Appwrite\Platform\Modules\Teams\Http\Preferences\Get as GetPreferences;
use Appwrite\Platform\Modules\Teams\Http\Preferences\Update as UpdatePreferences;
use Appwrite\Platform\Modules\Teams\Http\Teams\Create as CreateTeam;
use Appwrite\Platform\Modules\Teams\Http\Teams\Delete as DeleteTeam;
use Appwrite\Platform\Modules\Teams\Http\Teams\Get as GetTeam;
use Appwrite\Platform\Modules\Teams\Http\Teams\Name\Update as UpdateTeamName;
use Appwrite\Platform\Modules\Teams\Http\Teams\XList as ListTeams;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
// Teams
$this->addAction(CreateTeam::getName(), new CreateTeam());
$this->addAction(GetTeam::getName(), new GetTeam());
$this->addAction(ListTeams::getName(), new ListTeams());
$this->addAction(DeleteTeam::getName(), new DeleteTeam());
$this->addAction(UpdateTeamName::getName(), new UpdateTeamName());
// Preferences
$this->addAction(GetPreferences::getName(), new GetPreferences());
$this->addAction(UpdatePreferences::getName(), new UpdatePreferences());
// Memberships
$this->addAction(CreateMembership::getName(), new CreateMembership());
$this->addAction(GetMembership::getName(), new GetMembership());
$this->addAction(ListMemberships::getName(), new ListMemberships());
$this->addAction(UpdateMembership::getName(), new UpdateMembership());
$this->addAction(DeleteMembership::getName(), new DeleteMembership());
$this->addAction(UpdateMembershipStatus::getName(), new UpdateMembershipStatus());
// Logs
$this->addAction(ListLogs::getName(), new ListLogs());
}
}
@@ -0,0 +1,140 @@
<?php
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External;
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
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\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
class Update extends Action
{
use HTTP;
use Deployment;
public static function getName()
{
return 'updateExternalDeployment';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/vcs/github/installations/:installationId/repositories/:repositoryId')
->desc('Update external deployment (authorize)')
->groups(['api', 'vcs'])
->label('scope', 'vcs.write')
->label('sdk', new Method(
namespace: 'vcs',
group: 'repositories',
name: 'updateExternalDeployments',
description: '/docs/references/vcs/update-external-deployments.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
]
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('repositoryId', '', new Text(256), 'VCS Repository Id')
->param('providerPullRequestId', '', new Text(256), 'GitHub Pull Request Id')
->inject('gitHub')
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->callback($this->action(...));
}
public function action(
string $installationId,
string $repositoryId,
string $providerPullRequestId,
GitHub $github,
Response $response,
Document $project,
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
Build $queueForBuilds,
array $platform
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
}
$repository = $authorization->skip(fn () => $dbForPlatform->findOne('repositories', [
Query::equal('$id', [$repositoryId]),
Query::equal('projectInternalId', [$project->getSequence()])
]));
if ($repository->isEmpty()) {
throw new Exception(Exception::REPOSITORY_NOT_FOUND);
}
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
throw new Exception(Exception::PROVIDER_CONTRIBUTION_CONFLICT);
}
$providerPullRequestIds = \array_unique(\array_merge($repository->getAttribute('providerPullRequestIds', []), [$providerPullRequestId]));
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['providerPullRequestIds' => $providerPullRequestIds])));
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$providerInstallationId = $installation->getAttribute('providerInstallationId');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$repositories = [$repository];
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$providerRepositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($providerRepositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$pullRequestResponse = $github->getPullRequest($owner, $providerRepositoryName, $providerPullRequestId);
$providerRepositoryUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['owner']['login'] ?? '';
$providerBranch = \explode(':', $pullRequestResponse['head']['label'])[1] ?? '';
$providerBranchUrl = "$providerRepositoryUrl/tree/$providerBranch";
$providerCommitHash = $pullRequestResponse['head']['sha'] ?? '';
$commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash);
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
$providerCommitUrl = $commitDetails["commitUrl"] ?? '';
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
$response->noContent();
}
}
@@ -4,13 +4,12 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Callback;
use Appwrite\Auth\OAuth2\Github as OAuth2Github;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Permission as AppwritePermission;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -21,6 +20,7 @@ use Utopia\VCS\Adapter\Git\GitHub;
class Get extends Action
{
use HTTP;
use AppwritePermission;
public static function getName()
{
@@ -132,13 +132,7 @@ class Get extends Action
$installation = new Document([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
Permission::update(Role::team(ID::custom($teamId), 'owner')),
Permission::update(Role::team(ID::custom($teamId), 'developer')),
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
],
'$permissions' => $this->getPermissions($teamId, $projectId),
'providerInstallationId' => $providerInstallationId,
'projectId' => $projectId,
'projectInternalId' => $projectInternalId,
@@ -158,7 +152,13 @@ class Get extends Action
->setAttribute('personalRefreshToken', $refreshToken)
->setAttribute('personalAccessToken', $accessToken)
->setAttribute('personalAccessTokenExpiry', $accessTokenExpiry);
$installation = $dbForPlatform->updateDocument('installations', $installation->getId(), $installation);
$installation = $dbForPlatform->updateDocument('installations', $installation->getId(), new Document([
'organization' => $installation->getAttribute('organization'),
'personal' => $installation->getAttribute('personal'),
'personalRefreshToken' => $installation->getAttribute('personalRefreshToken'),
'personalAccessToken' => $installation->getAttribute('personalAccessToken'),
'personalAccessTokenExpiry' => $installation->getAttribute('personalAccessTokenExpiry'),
]));
}
} else {
$error = 'Installation of the Appwrite GitHub App on organization accounts is restricted to organization owners. As a member of the organization, you do not have the necessary permissions to install this GitHub App. Please contact the organization owner to create the installation from the Appwrite console.';
@@ -0,0 +1,528 @@
<?php
namespace Appwrite\Platform\Modules\VCS\Http\GitHub;
use Appwrite\Event\Build;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Vcs\Comment;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\DSN\DSN;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
trait Deployment
{
protected function createGitDeployments(
GitHub $github,
string $providerInstallationId,
array $repositories,
string $providerBranch,
string $providerBranchUrl,
string $providerRepositoryName,
string $providerRepositoryUrl,
string $providerRepositoryOwner,
string $providerCommitHash,
string $providerCommitAuthor,
string $providerCommitAuthorUrl,
string $providerCommitMessage,
string $providerCommitUrl,
string $providerPullRequestId,
bool $external,
Database $dbForPlatform,
Authorization $authorization,
Build $queueForBuilds,
callable $getProjectDB,
array $platform,
) {
$errors = [];
foreach ($repositories as $repository) {
try {
$repositoryId = $repository->getId();
$projectId = $repository->getAttribute('projectId');
$resourceId = $repository->getAttribute('resourceId');
$resourceType = $repository->getAttribute('resourceType');
$logBase = "vcs.github.event.repo.{$repositoryId}";
Span::add("{$logBase}.projectId", $projectId);
Span::add("{$logBase}.resourceId", $resourceId);
Span::add("{$logBase}.resourceType", $resourceType);
if ($resourceType !== "function" && $resourceType !== "site") {
continue;
}
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project');
}
try {
$dsn = new DSN($project->getAttribute('database'));
$databaseName = $dsn->getHost();
} catch (\InvalidArgumentException) {
$databaseName = $project->getAttribute('database');
}
$databases = Config::getParam('pools-database', []);
$index = in_array($databaseName, $databases);
if ($index === false) {
Console::error("Database: '{$databaseName}' is not part of region: " . System::getEnv('_APP_REGION'));
continue;
}
$dbForProject = $getProjectDB($project);
$resourceCollection = $resourceType === "function" ? 'functions' : 'sites';
$resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId));
$resourceInternalId = $resource->getSequence();
$deploymentId = ID::unique();
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
$installationId = $repository->getAttribute('installationId');
$installationInternalId = $repository->getAttribute('installationInternalId');
$productionBranch = $resource->getAttribute('providerBranch');
$activate = false;
if ($providerBranch == $productionBranch && $external === false) {
$activate = true;
}
$owner = $github->getOwnerName($providerInstallationId) ?? '';
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$isAuthorized = !$external;
if (!$isAuthorized && !empty($providerPullRequestId)) {
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
$isAuthorized = true;
}
}
Span::add("{$logBase}.authorized", $isAuthorized);
$commentStatus = 'waiting';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$hostname = $platform['consoleHostname'] ?? '';
$authorizeUrl = $protocol . '://' . $hostname . "/console/git/authorize-contributor?projectId={$projectId}&installationId={$installationId}&repositoryId={$repositoryId}&providerPullRequestId={$providerPullRequestId}";
$action = $isAuthorized ? ['type' => 'logs'] : ['type' => 'authorize', 'url' => $authorizeUrl];
$latestCommentId = '';
if (!empty($providerPullRequestId) && $resource->getAttribute('providerSilentMode', false) === false) {
$latestComment = $authorization->skip(fn () => $dbForPlatform->findOne('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerPullRequestId', [$providerPullRequestId]),
Query::orderDesc('$createdAt'),
]));
if (!$latestComment->isEmpty()) {
$latestCommentId = $latestComment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
} else {
$comment = new Comment($platform);
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment()));
if (!empty($latestCommentId)) {
$teamId = $project->getAttribute('teamId', '');
$latestComment = $authorization->skip(fn () => $dbForPlatform->createDocument('vcsComments', new Document([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
Permission::update(Role::team(ID::custom($teamId), 'owner')),
Permission::update(Role::team(ID::custom($teamId), 'developer')),
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
],
'installationInternalId' => $installationInternalId,
'installationId' => $installationId,
'projectInternalId' => $project->getSequence(),
'projectId' => $project->getId(),
'providerRepositoryId' => $providerRepositoryId,
'providerBranch' => $providerBranch,
'providerPullRequestId' => $providerPullRequestId,
'providerCommentId' => $latestCommentId
])));
}
}
} elseif (!empty($providerBranch)) {
$latestComments = $authorization->skip(fn () => $dbForPlatform->find('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerBranch', [$providerBranch]),
Query::orderDesc('$createdAt'),
]));
foreach ($latestComments as $comment) {
$latestCommentId = $comment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
}
if (!$isAuthorized) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$name = "{$resourceName} ({$projectName})";
$message = 'Authorization required for external contributor.';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $authorizeUrl, $name);
continue;
}
$commands = [];
if (!empty($resource->getAttribute('installCommand', ''))) {
$commands[] = $resource->getAttribute('installCommand', '');
}
if (!empty($resource->getAttribute('buildCommand', ''))) {
$commands[] = $resource->getAttribute('buildCommand', '');
}
if (!empty($resource->getAttribute('commands', ''))) {
$commands[] = $resource->getAttribute('commands', '');
}
$deployment = $authorization->skip(fn () => $dbForProject->createDocument('deployments', new Document([
'$id' => $deploymentId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
'resourceType' => $resourceCollection,
'entrypoint' => $resource->getAttribute('entrypoint', ''),
'buildCommands' => \implode(' && ', $commands),
'startCommand' => $resource->getAttribute('startCommand', ''),
'buildOutput' => $resource->getAttribute('outputDirectory', ''),
'adapter' => $resource->getAttribute('adapter', ''),
'fallbackFile' => $resource->getAttribute('fallbackFile', ''),
'type' => 'vcs',
'installationId' => $installationId,
'installationInternalId' => $installationInternalId,
'providerRepositoryId' => $providerRepositoryId,
'repositoryId' => $repositoryId,
'repositoryInternalId' => $repositoryInternalId,
'providerBranchUrl' => $providerBranchUrl,
'providerRepositoryName' => $providerRepositoryName,
'providerRepositoryOwner' => $providerRepositoryOwner,
'providerRepositoryUrl' => $providerRepositoryUrl,
'providerCommitHash' => $providerCommitHash,
'providerCommitAuthorUrl' => $providerCommitAuthorUrl,
'providerCommitAuthor' => $providerCommitAuthor,
'providerCommitMessage' => mb_strimwidth($providerCommitMessage, 0, 255, '...'),
'providerCommitUrl' => $providerCommitUrl,
'providerCommentId' => \strval($latestCommentId),
'providerBranch' => $providerBranch,
'activate' => $activate,
])));
$resource = $resource
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$authorization->skip(fn () => $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document([
'latestDeploymentId' => $resource->getAttribute('latestDeploymentId'),
'latestDeploymentInternalId' => $resource->getAttribute('latestDeploymentInternalId'),
'latestDeploymentCreatedAt' => $resource->getAttribute('latestDeploymentCreatedAt'),
'latestDeploymentStatus' => $resource->getAttribute('latestDeploymentStatus'),
])));
if ($resource->getCollection() === 'sites') {
$projectId = $project->getId();
// Deployment preview
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$previewRuleId = $ruleId;
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
// VCS branch preview
if (!empty($providerBranch)) {
$domain = (new BranchDomainFilter())->apply([
'branch' => $providerBranch,
'resourceId' => $resource->getId(),
'projectId' => $project->getId(),
'sitesDomain' => $sitesDomain,
]);
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
// VCS commit preview
if (!empty($providerCommitHash)) {
$domain = "commit-" . substr($providerCommitHash, 0, 16) . ".{$sitesDomain}";
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
}
if ($resource->getCollection() === 'sites' && !empty($latestCommentId) && !empty($previewRuleId)) {
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $previewRuleId));
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '';
if (!empty($previewUrl)) {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $previewUrl);
$github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment());
}
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
if (!empty($providerCommitHash) && $resource->getAttribute('providerSilentMode', false) === false) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$region = $project->getAttribute('region', 'default');
$name = "{$resourceName} ({$projectName})";
$message = 'Starting...';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$providerTargetUrl = $protocol . '://' . $hostname . "/console/project-$region-$projectId/$resourceCollection/$resourceType-$resourceId";
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $providerTargetUrl, $name);
}
$queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization);
$queueForBuilds
->setQueue($queueName)
->setType(BUILD_TYPE_DEPLOYMENT)
->setResource($resource)
->setDeployment($deployment)
->setProject($project); // set the project because it won't be set for git deployments
$queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
Span::add("{$logBase}.build.triggered", 'true');
//TODO: Add event?
} catch (\Throwable $e) {
Span::add("{$logBase}.error", $e->getMessage());
$errors[] = $e->getMessage();
}
}
$queueForBuilds->reset(); // prevent shutdown hook from triggering again
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
}
protected function getBuildQueueName(Document $project, Database $dbForPlatform, Authorization $authorization): string
{
return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME);
}
}
@@ -0,0 +1,245 @@
<?php
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events;
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Scope\HTTP;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\VCS\Adapter\Git\GitHub;
class Create extends Action
{
use HTTP;
use Deployment;
public static function getName()
{
return 'createVCSGitHubEvent';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/vcs/github/events')
->desc('Create event')
->groups(['api', 'vcs'])
->label('scope', 'public')
->inject('gitHub')
->inject('request')
->inject('response')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->callback($this->action(...));
}
public function action(
GitHub $github,
Request $request,
Response $response,
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
Build $queueForBuilds,
array $platform
) {
$this->preprocessEvent($request);
$event = $request->getHeader('x-github-event', '');
Span::add('vcs.github.event.name', $event);
$payload = $request->getRawPayload();
$signature = $request->getHeader('x-hub-signature-256', '');
$secretKey = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', '');
$valid = empty($signature) ? true : $github->validateWebhookEvent($payload, $signature, $secretKey);
Span::add('vcs.github.event.signature.valid', $valid);
if (!$valid) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, "Invalid webhook payload signature. Please make sure the webhook secret has same value in your GitHub app and in the _APP_VCS_GITHUB_WEBHOOK_SECRET environment variable");
}
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$parsedPayload = $github->getEvent($event, $payload);
match ($event) {
$github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization),
$github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
$github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
default => null,
};
return $response->json($parsedPayload);
}
protected function preprocessEvent(Request $request)
{
return;
}
private function handleInstallationEvent(
array $parsedPayload,
Database $dbForPlatform,
Authorization $authorization,
) {
if ($parsedPayload["action"] !== "deleted") {
return;
}
// TODO: Use worker for this job instead (update function/site as well)
$providerInstallationId = $parsedPayload["installationId"];
$installations = $dbForPlatform->find('installations', [
Query::equal('providerInstallationId', [$providerInstallationId]),
Query::limit(1000)
]);
foreach ($installations as $installation) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('installationInternalId', [$installation->getSequence()]),
Query::limit(1000)
]));
foreach ($repositories as $repository) {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('repositories', $repository->getId()));
}
$authorization->skip(fn () => $dbForPlatform->deleteDocument('installations', $installation->getId()));
}
}
private function handlePushEvent(
array $parsedPayload,
string $githubAppId,
string $privateKey,
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
Build $queueForBuilds,
callable $getProjectDB,
array $platform,
) {
$providerBranchCreated = $parsedPayload["branchCreated"] ?? false;
$providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false;
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$providerCommitAuthorName = $parsedPayload["headCommitAuthorName"] ?? '';
$providerCommitAuthorEmail = $parsedPayload["headCommitAuthorEmail"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
$providerCommitMessage = $parsedPayload["headCommitMessage"] ?? '';
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
Span::add('vcs.github.event.repo.id', $providerRepositoryId);
Span::add('vcs.github.event.repo.name', $providerRepositoryName);
Span::add('vcs.github.event.branch', $providerBranch);
Span::add('vcs.github.event.installation.id', $providerInstallationId);
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
// Find associated repositories
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::limit(100),
]));
// Create new deployment only on push (not committed by us) and not when branch is created or deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) {
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
}
}
private function handlePullRequestEvent(
array $parsedPayload,
string $privateKey,
string $githubAppId,
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
Build $queueForBuilds,
callable $getProjectDB,
array $platform,
) {
$action = $parsedPayload["action"] ?? '';
if ($action == "opened" || $action == "reopened" || $action == "synchronize") {
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$external = $parsedPayload["external"] ?? true;
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
Span::add('vcs.github.event.repo.id', $providerRepositoryId);
Span::add('vcs.github.event.repo.name', $providerRepositoryName);
Span::add('vcs.github.event.branch', $providerBranch);
Span::add('vcs.github.event.installation.id', $providerInstallationId);
// Ignore sync for non-external. We handle it in push webhook
if (!$external && $parsedPayload["action"] == "synchronize") {
return;
}
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash);
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
} elseif ($action == "closed") {
// Allowed external contributions cleanup
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$external = $parsedPayload["external"] ?? true;
if ($external) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
foreach ($repositories as $repository) {
$providerPullRequestIds = $repository->getAttribute('providerPullRequestIds', []);
if (\in_array($providerPullRequestId, $providerPullRequestIds)) {
$providerPullRequestIds = \array_diff($providerPullRequestIds, [$providerPullRequestId]);
$repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds);
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['providerPullRequestIds' => $providerPullRequestIds])));
}
}
}
}
}
}

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