Compare commits

...
Author SHA1 Message Date
ArnabChatterjee20kandGitHub 1ca75c73df Merge branch '1.9.x' into realtime-logs 2026-04-24 16:35:25 +05:30
ArnabChatterjee20k 0633662695 removed dispatch experiment 2026-04-24 16:22:57 +05:30
ArnabChatterjee20k 89819db775 added exporter 2026-04-24 16:12:42 +05:30
Matej BačoandGitHub 29b700d1ec Merge pull request #11981 from appwrite/feat-public-list-endpoints
Feat: Project public list endpoints
2026-04-24 10:08:23 +02:00
Matej Bačo e3231393b9 Fix anayser 2026-04-23 16:06:45 +02:00
Matej Bačo 5beeca5a99 Placeholder test 2026-04-23 15:57:09 +02:00
Matej Bačo 4de3009f67 Fix analyser 2026-04-23 15:36:16 +02:00
Matej Bačo 4b3963512c Linter fix 2026-04-23 15:28:20 +02:00
Matej Bačo 8c634a95e4 Fix failing tests 2026-04-23 15:28:10 +02:00
Matej Bačo 7a3c001452 Re-add project removal tests 2026-04-23 15:22:40 +02:00
Matej Bačo a48fd13ced Add getPolicy + tests + move wrongly placed project tests 2026-04-23 15:19:49 +02:00
Matej Bačo 9c6ed9565e Remove tests of removed endpoints 2026-04-23 14:07:58 +02:00
Matej Bačo bdbc5b92df Fix after code review 2026-04-23 13:47:31 +02:00
Matej Bačo c246fb0f83 Project deletion tests 2026-04-23 13:41:11 +02:00
Matej Bačo a0a3849b16 Remove unsupported bulk endpoints 2026-04-23 13:37:32 +02:00
Matej Bačo b99139661e Migrate delete project endpoint 2026-04-23 13:37:19 +02:00
Matej Bačo 6d86b8fd0d Removal of project JWTs 2026-04-23 13:25:21 +02:00
Matej Bačo cef7a5197f List policies API 2026-04-23 13:24:39 +02:00
Matej Bačo c1dfeae323 Add queries to email tempaltes list 2026-04-23 13:06:05 +02:00
Matej Bačo 51fa0770a6 Add queries to mock numbers list 2026-04-23 12:43:45 +02:00
Matej BačoandGitHub d46403507c Merge pull request #11979 from appwrite/fix-membership-privacy
Fix: membership privacy bug on production
2026-04-23 10:47:54 +02:00
Matej Bačo 83724ce96f Console membership privacy test coverage 2026-04-23 10:37:35 +02:00
Matej Bačo 34930e6d67 Merge branch '1.9.x' into fix-membership-privacy 2026-04-23 10:18:32 +02:00
Matej BačoandGitHub 096f8041fd Merge pull request #11970 from appwrite/feat-mocks-public-api
Feat: Public mock phone APIs
2026-04-23 10:18:14 +02:00
Matej Bačo 9dad7cef9e Merge branch '1.9.x' into feat-mocks-public-api 2026-04-23 10:17:32 +02:00
Matej BačoandGitHub 9e23867f0a Merge pull request #11976 from appwrite/feat-auth-methods-api
Feat: Auth methods public API
2026-04-23 10:14:34 +02:00
Matej BačoGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
48353faa9b Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-23 10:13:01 +02:00
Matej Bačo c36b8fbabf Fix membershiip privacy bug on production 2026-04-23 10:07:32 +02:00
Luke B. SilverandGitHub 5a5cb1e74e Merge pull request #11977 from appwrite/perf/v20-investigation
perf: memoize request filter chain and V20 schema lookups
2026-04-23 07:38:03 +01:00
Chirag AggarwalandGitHub a8e6b1b683 Merge pull request #11963 from appwrite/chore/http-benchmark-comparison 2026-04-23 09:48:37 +05:30
Matej Bačo b0939b92c3 Fix failing account tests 2026-04-22 17:02:22 +02:00
Chirag Aggarwal 3d66078fe9 Increase benchmark iterations 2026-04-22 19:46:27 +05:30
Chirag Aggarwal 9a6a597710 Address benchmark hardening review 2026-04-22 19:38:48 +05:30
Chirag Aggarwal c15e8d0126 Harden benchmark failure guard 2026-04-22 19:30:01 +05:30
Chirag Aggarwal 7b25d778d4 Trim benchmark scenarios 2026-04-22 19:21:51 +05:30
loks0n 3283b0bec0 perf: memoize request filter chain and V20 schema lookups
A phpspy profile of a production databases worker showed the V20
backwards-compat request filter accounting for ~40% of in-request
samples on `databases.listDocuments` traffic. Two compounding causes:

1. `Request::getParams()` re-ran the entire filter chain on every
   invocation. The framework and app call `getParams()` several times
   per request (route param binding, `cacheIdentifier()`, action
   injection, logging), so V20's recursive schema walk executed N times
   with identical inputs.
2. Inside `V20::getRelatedCollectionKeys`, the `databases/$databaseId`
   document was fetched at every recursion frame (up to
   RELATION_MAX_DEPTH = 3), and sibling relationships pointing at the
   same related collection each did their own `getDocument` call.

This commit:

- Memoizes the post-filter params on `Request`. The cache is
  invalidated by `addFilter`, `resetFilters`, and `setRoute`. `Request`
  is constructed per HTTP request (app/http.php), so the memo is
  naturally request-scoped. Helps every request filter version, not
  just V20.
- Splits V20's walk into an entry point that resolves the database
  namespace once and a pure recursive helper.
- Caches the collection `attributes` array per
  `(databaseNamespace, collectionId)` on the filter instance, so shared
  related collections collapse to one `getDocument` call. Missing or
  errored lookups are cached as `null` to avoid retry storms.
2026-04-22 14:49:13 +01:00
ArnabChatterjee20kandGitHub 49f8d6e89c Merge pull request #11971 from appwrite/realtime-logs
Structured Logging
2026-04-22 19:10:36 +05:30
Matej Bačo a0274a7b6f Fix failing tests 2026-04-22 15:12:58 +02:00
Chirag AggarwalandGitHub 277a09847e Merge pull request #11975 from appwrite/t3code/database-tables-retry-fix
[codex] Stabilize database e2e CI retries
2026-04-22 18:34:29 +05:30
Chirag AggarwalandGitHub cdc5654c26 Merge branch '1.9.x' into chore/http-benchmark-comparison 2026-04-22 18:24:50 +05:30
Matej Bačo bb4fdefee7 New tests for auth methods (base + integration) 2026-04-22 14:51:10 +02:00
ArnabChatterjee20k c2e5bbe0f7 updated 2026-04-22 18:11:32 +05:30
Chirag Aggarwal b2d24080b9 Stabilize database e2e CI retries 2026-04-22 18:08:45 +05:30
ArnabChatterjee20k b006858d0c dedupe 2026-04-22 17:52:45 +05:30
Chirag AggarwalandGitHub a0f30f608d Merge pull request #11974 from appwrite/fix/no-rule-without-deployment 2026-04-22 17:52:02 +05:30
Matej Bačo a85c5e582c Add auth method APIs (public) 2026-04-22 14:19:04 +02:00
ArnabChatterjee20k 6d1def7716 removed redundant span attributes 2026-04-22 17:45:58 +05:30
ArnabChatterjee20k f0f1e1c412 updated 2026-04-22 17:44:18 +05:30
Chirag AggarwalandGitHub 11f85e2477 Merge branch '1.9.x' into chore/http-benchmark-comparison 2026-04-22 17:24:19 +05:30
Chirag AggarwalandGitHub dbb1d1139e Merge branch '1.9.x' into fix/no-rule-without-deployment 2026-04-22 17:20:31 +05:30
Chirag AggarwalandGitHub f0e2a8b2c4 Merge pull request #11972 from appwrite/t3code/sentry-bug-fix 2026-04-22 17:18:15 +05:30
ArnabChatterjee20k 46e778ea90 updated 2026-04-22 17:13:35 +05:30
ArnabChatterjee20k fd9fe5d9ce corrected the position 2026-04-22 17:07:53 +05:30
ArnabChatterjee20k 59e0383264 updated 2026-04-22 17:01:08 +05:30
ArnabChatterjee20k 0f81bc2da9 Refactor telemetry logging in realtime events for consistency and clarity
- Updated span logging keys to use camelCase for uniformity across connection and message events.
- Added checks to ensure project and user IDs are only logged if they are not empty, enhancing data integrity.
- Improved error handling and logging structure to maintain consistency in telemetry data.
2026-04-22 16:57:51 +05:30
ArnabChatterjee20k 17e3d03b40 Add telemetry logging for subscribed channels and queries in realtime events
- Introduced new arrays to capture subscribed channels and passed queries during connection and message events.
- Enhanced span logging to include details about channels and queries for better monitoring and analysis.
- Updated telemetry data structure to reflect the new metrics, improving traceability of realtime interactions.
2026-04-22 16:55:52 +05:30
Chirag Aggarwal c97435d95c Stabilize benchmark wait metric tags 2026-04-22 16:53:35 +05:30
ArnabChatterjee20k b2ad7237ab Add detailed telemetry logging for realtime connection events
- Introduced span logging for connection open and close events, capturing metrics such as inbound and outbound bytes, subscription counts, and response codes.
- Enhanced error handling with logging of exceptions during connection lifecycle.
- Updated the structure of the telemetry data to include project and user IDs for better traceability.
2026-04-22 16:52:54 +05:30
ArnabChatterjee20k 57d777f80a revert format 2026-04-22 16:46:18 +05:30
Chirag Aggarwal d1962dbc62 Shorten local benchmark command 2026-04-22 16:45:16 +05:30
Matej Bačo 6648a1987b Fix tests 2026-04-22 13:14:43 +02:00
ArnabChatterjee20k c60e85ca84 Merge remote-tracking branch 'origin/1.9.x' into realtime-logs 2026-04-22 16:44:34 +05:30
ArnabChatterjee20k ca1cf1982f added wide events inside the structured one logging per message instead of a discrete logs 2026-04-22 16:44:20 +05:30
Chirag Aggarwal f50ca0281b Drop dead ternary guards now that outer check ensures deployment 2026-04-22 16:43:28 +05:30
Chirag AggarwalandGitHub 2f12717f30 Merge pull request #11969 from appwrite/t3code/spec-overlap-verification 2026-04-22 16:28:34 +05:30
Chirag Aggarwal f934259c31 Skip preview rule when no deployment exists on function create 2026-04-22 16:26:41 +05:30
Chirag AggarwalandGitHub 838dbc52e3 Merge branch '1.9.x' into t3code/sentry-bug-fix 2026-04-22 16:23:25 +05:30
Chirag Aggarwal e7d9ef74c4 Fix deployment single chunk content range
Fixes CLOUD-3JN6
2026-04-22 16:05:12 +05:30
Chirag Aggarwal d106e1d5bb Fix session alert test payload 2026-04-22 15:57:32 +05:30
Matej Bačo d1ade3872e Fix failing tests 2026-04-22 12:22:00 +02:00
Chirag Aggarwal 0f64f54221 Harden benchmark rerun metrics 2026-04-22 15:49:27 +05:30
Chirag Aggarwal 00512df4ca Clean up spec enum validation reporting 2026-04-22 15:45:16 +05:30
Matej Bačo 9065d9ada4 Add mocks scopes 2026-04-22 12:13:10 +02:00
ArnabChatterjee20k e0fec8f550 updated 2026-04-22 15:42:17 +05:30
Chirag Aggarwal 205c283935 Remove unused JWT benchmark setup 2026-04-22 15:33:52 +05:30
Chirag Aggarwal 038be90969 Preserve nested items enum validation 2026-04-22 15:33:18 +05:30
Matej Bačo 355d4323fc Fix tests not running 2026-04-22 12:01:42 +02:00
Matej Bačo 7578b5644c AI review fixes 2026-04-22 12:00:15 +02:00
Chirag Aggarwal 240cdf43e5 Simplify local benchmark command 2026-04-22 15:24:47 +05:30
Chirag Aggarwal 481eaf7530 Merge remote-tracking branch 'origin/1.9.x' into t3code/spec-overlap-verification 2026-04-22 15:24:34 +05:30
Chirag Aggarwal 2390d40731 Remove specs task unit test 2026-04-22 15:24:13 +05:30
Chirag Aggarwal f75a7269c9 Address benchmark review simplifications 2026-04-22 15:23:35 +05:30
Chirag Aggarwal 4f74394e8f Fix spec enum name validation 2026-04-22 15:21:41 +05:30
Matej Bačo f770277ea5 New mock phones tests 2026-04-22 11:42:39 +02:00
Matej Bačo eeadba3b59 Add missing endpoint in email templates 2026-04-22 11:36:54 +02:00
Matej Bačo 2e42633e12 Add public mocks API for phones 2026-04-22 11:30:39 +02:00
Chirag Aggarwal affd5876ab Add spec enum service overlap validation 2026-04-22 14:49:35 +05:30
Chirag Aggarwal dfd39d3946 Tolerate benchmark cleanup failures 2026-04-22 14:25:59 +05:30
Chirag Aggarwal 7d7fcea8c0 Ensure benchmark failures fail CI 2026-04-22 14:16:12 +05:30
Chirag Aggarwal 33d3f82a58 Merge remote-tracking branch 'origin/1.9.x' into chore/http-benchmark-comparison
# Conflicts:
#	.github/workflows/ci.yml
2026-04-22 14:05:45 +05:30
Matej BačoandGitHub af531ee4f9 Merge pull request #11964 from appwrite/feat-public-project-policies
Feat: public project policies
2026-04-22 10:23:10 +02:00
Chirag Aggarwal 73a77b8dcc Show benchmark throughput 2026-04-22 13:45:50 +05:30
Chirag Aggarwal 4b1b2972e9 Merge remote-tracking branch 'origin/1.9.x' into chore/http-benchmark-comparison
# Conflicts:
#	.github/workflows/ci.yml
2026-04-22 13:34:45 +05:30
Matej Bačo 72bb6378c2 Leftover 2026-04-22 10:00:19 +02:00
Matej Bačo bfa1960d8a Remove unneeded const 2026-04-22 10:00:10 +02:00
Matej Bačo e530bf41f7 Post-merge fix 2026-04-22 09:59:00 +02:00
Matej Bačo 0d27c59cb8 Merge branch '1.9.x' into feat-public-project-policies 2026-04-22 09:57:48 +02:00
Matej BačoandGitHub 97e611029e Merge pull request #11900 from appwrite/feat-project-smtp-endpoints
Feat: Public API for project SMTP endpoints
2026-04-22 09:52:09 +02:00
Matej Bačo efc37c68ec Merge branch '1.9.x' into feat-project-smtp-endpoints 2026-04-22 09:50:08 +02:00
Chirag Aggarwal b3f305f9a8 Record storage upload wait metric 2026-04-22 13:02:16 +05:30
Chirag Aggarwal 32508e7251 Avoid reserved TablesDB benchmark column name 2026-04-22 12:53:13 +05:30
Chirag Aggarwal a98b9f2319 Handle malformed optional benchmark summaries 2026-04-22 12:06:32 +05:30
Chirag Aggarwal a0ef5968fb Document local HTTP benchmark command 2026-04-22 11:58:30 +05:30
Chirag Aggarwal bc637ad25f Merge remote-tracking branch 'origin/1.9.x' into chore/http-benchmark-comparison
# Conflicts:
#	.github/workflows/ci.yml
2026-04-22 11:38:04 +05:30
Chirag AggarwalandGitHub 5b6dd5f75a Merge pull request #11949 from appwrite/chore/phpstan-level-4 2026-04-22 11:17:21 +05:30
Jake BarnbyandGitHub 2c973387c5 Merge pull request #11967 from appwrite/fix/listrows-total-int-cast
fix: cast cached total to int in listDocuments/listRows
2026-04-22 17:02:43 +12:00
Chirag Aggarwal 3b9c604eb8 Harden benchmark comparison run 2026-04-22 09:45:51 +05:30
Jake BarnbyandClaude Opus 4.7 8a841a6971 fix: cast cached total to int in listDocuments/listRows
Redis stringifies scalars on save, so on a cache hit the `total` field
was served as a string. Flutter SDK (and any strictly-typed client) then
failed with `TypeError: "37": type 'String' is not a subtype of type 'int'`.
The cache-miss path returned an int from `count()`, which is why only
repeat requests with `ttl > 0` tripped the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:14:30 +12:00
Chirag Aggarwal c973ca0a5d Address PHPStan level 4 review feedback 2026-04-22 09:34:03 +05:30
Chirag Aggarwal 7c486ddcef Keep benchmark comment on missing summary 2026-04-22 09:26:36 +05:30
Chirag Aggarwal cb7f2ec693 Show top benchmark request waits 2026-04-22 09:19:26 +05:30
Chirag Aggarwal 3cc7b833db Fix k6 benchmark diagnostics 2026-04-22 09:06:54 +05:30
Chirag Aggarwal 9ca84a56c9 Switch HTTP benchmark back to k6 2026-04-22 08:51:51 +05:30
Damodar LohaniandGitHub af05ad5784 Merge pull request #11953 from appwrite/CLO-4204-slow-query-hook
feat: add afterQuery hook to listRows (tablesdb) action
2026-04-22 07:25:24 +05:45
Matej Bačo 39af9c544d Remove unnessessary backwards compatibility 2026-04-21 18:22:44 +02:00
Luke B. SilverandGitHub 10b6955e98 Merge pull request #11965 from appwrite/fix/support-worker-num
fix: honor _APP_WORKERS_NUM in realtime
2026-04-21 17:14:08 +01:00
loks0nandClaude Opus 4.7 7e963e8439 fix: honor _APP_WORKERS_NUM in realtime
Realtime was ignoring _APP_WORKERS_NUM and always computing workers as
CPU × _APP_WORKER_PER_CORE, making it impossible to cap the worker count
without also changing the per-core multiplier. Prefer _APP_WORKERS_NUM
when set, falling back to the CPU × per-core calculation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:55:56 +01:00
Matej Bačo d9d5a81337 Fix 0->null backwrds compatibility 2026-04-21 17:51:31 +02:00
Matej Bačo 193891a7c9 Fix analyzer 2026-04-21 17:38:32 +02:00
Matej Bačo 70cb5ca68a linter fix 2026-04-21 17:34:59 +02:00
Matej Bačo c8a1746119 Fix existing tests 2026-04-21 17:34:44 +02:00
Matej Bačo 06eb550e98 Finalize tests 2026-04-21 16:56:00 +02:00
Matej Bačo 6c89a05a60 Fix 0 session to mean unlimited 2026-04-21 16:38:53 +02:00
Matej BačoandGitHub 1b993ede0f Merge branch '1.9.x' into feat-project-smtp-endpoints 2026-04-21 16:15:00 +02:00
Matej Bačo 648ffcdcfa Fix event labels 2026-04-21 16:07:52 +02:00
Matej Bačo 0be94a7aff Fix tests 2026-04-21 15:57:30 +02:00
Matej Bačo eba145ee2d More integration tests 2026-04-21 15:13:27 +02:00
Matej Bačo 99d230c70d Integration tests 2026-04-21 14:39:02 +02:00
Chirag Aggarwal 4ac1b68bbc Fix OpenAPI enum keys analysis 2026-04-21 18:03:18 +05:30
Chirag Aggarwal 211ac32080 Guard benchmark account-dependent flows 2026-04-21 17:59:09 +05:30
Chirag Aggarwal 30bf9deae0 Remove benchmark failure rows from output 2026-04-21 17:54:36 +05:30
Chirag Aggarwal 196b04a39c Polish benchmark comment details 2026-04-21 17:49:50 +05:30
Matej Bačo 6adabae620 Add policy tests 2026-04-21 14:05:46 +02:00
Matej Bačo 5f9dc0fcd8 Req & res filters, review fixes 2026-04-21 13:58:36 +02:00
Chirag Aggarwal b9d01617a4 Address benchmark review hardening 2026-04-21 17:24:26 +05:30
Chirag Aggarwal 9c65609d73 Check benchmark summary writes 2026-04-21 17:13:00 +05:30
Chirag AggarwalandGitHub da4dcd8505 Merge branch '1.9.x' into chore/phpstan-level-4 2026-04-21 17:08:46 +05:30
Matej Bačo d0f853d4cd Add more project policies 2026-04-21 13:38:27 +02:00
Chirag Aggarwal 63b2a1fb7f Harden benchmark baseline reporting 2026-04-21 17:07:05 +05:30
Chirag Aggarwal 30cfbb2d99 Polish benchmark reporting 2026-04-21 16:58:31 +05:30
Matej Bačo 2f272f0480 Cleanup unused markdowns descriptions 2026-04-21 13:27:08 +02:00
Matej Bačo 4317ee5617 Move some of auth settings to project policies 2026-04-21 13:11:42 +02:00
Chirag Aggarwal ef08d5a04c Run before benchmark with base image 2026-04-21 16:33:09 +05:30
Chirag Aggarwal 566eebfaec Fix benchmark PNG fixture 2026-04-21 16:31:57 +05:30
Chirag Aggarwal 51bc3dc1d5 Migrate HTTP benchmark to PHP 2026-04-21 16:22:04 +05:30
Chirag Aggarwal 83f182b444 Address benchmark review feedback 2026-04-21 16:10:08 +05:30
Chirag Aggarwal dcd01a8fb0 Tidy benchmark PR comment 2026-04-21 16:06:30 +05:30
Chirag Aggarwal 6aeb2d2be0 Fix benchmark before branch comparison 2026-04-21 15:51:30 +05:30
Chirag Aggarwal 2cfe40e98e Compare benchmark against base branch 2026-04-21 15:11:24 +05:30
Chirag Aggarwal 15e45df81e Address HTTP benchmark review feedback 2026-04-21 15:07:20 +05:30
Chirag Aggarwal e4f74a3fb1 Run curated HTTP benchmark in CI 2026-04-21 14:56:33 +05:30
Chirag Aggarwal 774a0d7022 Improve HTTP benchmark coverage 2026-04-21 14:48:12 +05:30
Damodar LohaniandGitHub 1dc9217c65 Merge branch '1.9.x' into CLO-4204-slow-query-hook 2026-04-21 14:31:45 +05:45
Damodar Lohani 50bd2877f4 chore: shorten afterQuery docblock 2026-04-21 08:46:21 +00:00
Damodar Lohani f465e2267a chore: tighten afterQuery docblock 2026-04-21 08:43:20 +00:00
Damodar Lohani b4f1652286 refactor: simplify afterQuery DB timing to single wall-clock bracket
Replaces the per-call $measure closure with a single $dbStart
timestamp taken right before the fetch block and a single subtraction
right after it. Drops 6 lines of HOF indirection plus the $measure
variable, at the cost of including cache GET/SET time (~0.5–5ms) in
measurements when ttl > 0. For slow-query logging at a 100ms+
threshold that noise is negligible, and the default ttl=0 path has
no cache ops at all so the measurement is pure DB engine time.

The bracket captures the cursor lookup, find/count, and transaction
state calls — everything between "query parsed" and "fetch done",
as intended. processDocument's post-fetch relationship work is still
outside the bracket, matching the original design.
2026-04-21 08:41:11 +00:00
Damodar Lohani 4e4860e7f8 refactor: move afterQuery hook into base listDocuments action
Moves the DB-duration measurement and afterQuery() hook from the
tablesDB-specific Rows/XList into the shared
Databases/Collections/Documents/XList base. Because TablesDB Rows and
DocumentsDB Documents both extend the legacy listDocuments base, a
single override now covers all three endpoints: legacy
listDocuments, listDocumentsDBDocuments, and tablesDB listRows.

TablesDB Rows drops the ~200-line action() duplicate and keeps only
the path/params/SDK overrides it needs, plus the extra
->inject('utopia') so its injection chain matches the new base action
signature. DocumentsDB Documents gets the same one-line inject
addition. Net -165 lines of duplication removed.

Behaviour is unchanged for CE (afterQuery() is a no-op); downstream
distributions overriding afterQuery() now observe every list-documents
/ list-rows call site for free.
2026-04-21 08:28:12 +00:00
Matej Bačo cce04b3bd1 Improve test coverage 2026-04-21 10:28:11 +02:00
Harsh MahajanandGitHub 7568964b7c Merge pull request #11905 from appwrite/feat-add-telemetry-for-ss-success-rates
feat(sites): add telemetry to ss rates
2026-04-21 13:54:05 +05:30
Damodar LohaniandGitHub 82af29c0cf Merge branch '1.9.x' into CLO-4204-slow-query-hook 2026-04-21 14:04:29 +05:45
Harsh MahajanandGitHub c6672e93cf Merge branch '1.9.x' into feat-add-telemetry-for-ss-success-rates 2026-04-21 13:07:40 +05:30
ArnabChatterjee20kandGitHub ac905a80b5 Merge pull request #11954 from appwrite/realtime-time-metric
Realtime time metric
2026-04-21 11:14:27 +05:30
ArnabChatterjee20k 5df65d5417 updated 2026-04-21 10:58:34 +05:30
ArnabChatterjee20k abd6f0add2 updated 2026-04-21 10:52:21 +05:30
ArnabChatterjee20k 25854c84f5 Merge remote-tracking branch 'origin/1.9.x' into realtime-time-metric 2026-04-21 10:44:30 +05:30
Damodar Lohani 5724d19426 Merge remote-tracking branch 'origin/CLO-4204-slow-query-hook' into CLO-4204-slow-query-hook 2026-04-21 04:34:38 +00:00
Damodar Lohani 7c84337c5f Merge remote-tracking branch 'origin/1.9.x' into CLO-4204-slow-query-hook 2026-04-21 04:32:28 +00:00
Matej Bačo b1d37bc4be Fix remaining test failures 2026-04-20 23:03:16 +02:00
Matej Bačo 4808cad081 Fix most of Project tests 2026-04-20 23:01:53 +02:00
Matej Bačo 72cd7671c4 Fix projects tests 2026-04-20 23:01:00 +02:00
Matej Bačo c3e411fcaa Fix tests 2026-04-20 22:47:47 +02:00
Matej Bačo 8c31e9f206 Server error fixes 2026-04-20 22:36:47 +02:00
Matej Bačo a4ad1b6df3 Code quality improvs 2026-04-20 22:35:35 +02:00
Matej Bačo 9e94f15f02 Finalize tests 2026-04-20 22:23:34 +02:00
Matej Bačo e27719108b Add tests 2026-04-20 22:15:03 +02:00
Matej Bačo 8bf2f54a51 Leftovers 2026-04-20 22:04:46 +02:00
Matej Bačo 8c03db70e9 Finalize templates design 2026-04-20 22:04:20 +02:00
Matej Bačo 8ea69e0321 Fix password visibility test 2026-04-20 16:57:23 +02:00
Matej Bačo dfec2b3cb7 Improve test coverage 2026-04-20 16:11:22 +02:00
ArnabChatterjee20kandGitHub 69d778ab05 Merge pull request #11946 from appwrite/migration-via-api
added project region
2026-04-20 19:40:23 +05:30
Matej Bačo 848f09956e Improve backwards compatibility test coverage 2026-04-20 15:36:49 +02:00
Matej Bačo 51f50b161c Improved backwards compatibility 2026-04-20 15:28:05 +02:00
ArnabChatterjee20kandGitHub 53d3713bfe Merge pull request #11957 from appwrite/realtime-add-new-messages
Realtime add new messages
2026-04-20 18:46:25 +05:30
ArnabChatterjee20k b8385fe927 updated 2026-04-20 18:27:48 +05:30
Matej Bačo 52e3319a86 Linter fix 2026-04-20 14:50:12 +02:00
Matej Bačo 8b41aed919 Post-merge removal 2026-04-20 14:49:43 +02:00
ArnabChatterjee20k b2233193d5 updated 2026-04-20 18:19:12 +05:30
Matej Bačo ba4430801d Merge branch 'feat-project-templates-api' into feat-project-smtp-endpoints 2026-04-20 14:49:04 +02:00
Matej Bačo 1c1ec43150 Removeal post-merge 2026-04-20 14:47:01 +02:00
Matej Bačo 2f62cced0a Merge branch '1.9.x' into feat-project-smtp-endpoints 2026-04-20 14:46:42 +02:00
Matej Bačo afb8f70316 Fix tests 2026-04-20 14:42:52 +02:00
ArnabChatterjee20k 78eeac6d14 Add unsubscribe functionality and enhance subscription handling in Realtime tests 2026-04-20 17:38:01 +05:30
ArnabChatterjee20k 9f65177649 Add unsubscribe functionality to Realtime adapter 2026-04-20 17:37:54 +05:30
ArnabChatterjee20k e9ea39a822 Enhance Realtime adapter: support union of channels/roles on subscription and add unsubscribeSubscription method 2026-04-20 17:37:45 +05:30
Matej Bačo 2097b0a0b0 Better support for post-smtp changes 2026-04-20 14:04:53 +02:00
Matej Bačo 78ef52cc9e Manual QA fixes 2026-04-20 13:11:11 +02:00
Matej Bačo f040a4dc31 More backwards compatibility 2026-04-20 11:58:55 +02:00
Matej Bačo 56385ce167 Backwards compatibility 2026-04-20 11:48:45 +02:00
Matej Bačo bc592903db Support reply to name 2026-04-20 11:47:06 +02:00
Matej Bačo 06a2d48deb Linter fix 2026-04-20 11:29:13 +02:00
Matej Bačo c4d9c3dc4f Improve endpoint quality 2026-04-20 11:28:21 +02:00
Damodar LohaniandGitHub b36f1edeb0 Merge branch '1.9.x' into CLO-4204-slow-query-hook 2026-04-20 14:04:10 +05:45
Damodar Lohani eef443f07e chore: bump utopia-php/database to 5.3.22 for Query::fingerprint 2026-04-20 07:53:36 +00:00
ArnabChatterjee20k 62f7f25cb5 updated 2026-04-20 12:18:38 +05:30
ArnabChatterjee20k 4b94d14f1e updated time metric 2026-04-20 12:13:53 +05:30
ArnabChatterjee20k 12f76d74b1 added subscriptions telemetry and worker labelling to connections and subscriptions 2026-04-20 11:52:43 +05:30
Chirag AggarwalandGitHub 9f504cd065 Merge pull request #11950 from appwrite/add-claude-plugin 2026-04-20 11:48:40 +05:30
Jake BarnbyandGitHub 9dc27e9ca5 Merge pull request #11894 from appwrite/chore-remove-shared-v1
refactor: Remove shared tables V1/V2 versioning
2026-04-20 18:16:12 +12:00
ArnabChatterjee20k 2bff4192ee added telemetry for the connected clients 2026-04-20 11:45:05 +05:30
ArnabChatterjee20k a9fb17b067 added telemetry for message arriaval delay 2026-04-20 11:39:50 +05:30
Jake Barnby db3d00b1da Merge remote-tracking branch 'origin/1.9.x' into chore-remove-shared-v1 2026-04-20 18:04:26 +12:00
Atharva Deosthale 5ab42b8d32 update composer lock 2026-04-20 11:26:31 +05:30
ArnabChatterjee20k 8de7b41929 updated 2026-04-20 11:06:07 +05:30
Damodar Lohani a5c0a920ba feat: add afterQuery hook to list-documents/rows action
Wrap each database call (find, count, transaction list/count) with a
measuring closure so the actual DB duration is known — cache hits
report near-zero, cache misses report only the DB time, not cache
save / response serialization.

After the response is sent, invoke a protected afterQuery() hook with
the measured duration, the database/collection documents, and both
parsed + raw query arrays. CE impl is a no-op; downstreams (e.g.,
cloud) can override it to log slow queries without relying on HTTP
shutdown hooks or route-path matching.

Exceptions from afterQuery are swallowed so observability never
breaks the response.
2026-04-20 05:32:49 +00:00
Chirag Aggarwal 37a2b1cbd9 fix: restore executions limit cleanup behind a runtime env flag
Per review feedback on the PHPStan cleanup, the two `if
($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE)`
blocks in `app/controllers/general.php` and
`src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php`
were load-bearing feature flags, not dead code. Removing them silently
dropped the ability to turn the cleanup on later.

Changes:

- Convert `ENABLE_EXECUTIONS_LIMIT_ON_ROUTE` from
  `const ... = false;` to a `define()` backed by the new
  `_APP_EXECUTIONS_LIMIT_ON_ROUTE` env var (defaults to `disabled`).
  PHPStan can no longer fold the `&&` away since the value is now
  runtime-resolved, so the guarded blocks are live again.
- Restore the `/* cleanup */` block in the `router()` helper in
  `app/controllers/general.php`.
- Restore the two cleanup blocks in `Functions/Http/Executions/Create.php`
  (one on the async-scheduled return path, one on the sync-response
  path), and re-add the `DeleteEvent $queueForDeletes` /
  `int $executionsRetentionCount` injections plus the
  `Appwrite\Event\Delete` import.

Runtime behavior is identical to main (flag off by default); operators
can now flip it via env without a code change.
2026-04-20 08:54:31 +05:30
Matej Bačo 6b66923f18 Fix delete response placeholder audit label 2026-04-19 19:36:24 +02:00
Chirag Aggarwal adb4e4ef36 ci: fix benchmark by pulling compose from GitHub raw for the latest tag
`https://appwrite.io/install/compose` now returns a 308 redirect to the
HTML install docs (`/docs/advanced/self-hosting/installation`) instead
of serving the compose file, so the Benchmark job's "Installing latest
version" step was downloading 0 bytes and `docker compose up -d` died
with "empty compose file". This has been failing the Benchmark job on
every recent PR, not just this one.

Resolve the latest release tag via the GitHub API, then fetch the
compose file and `.env` from `raw.githubusercontent.com` at that tag.
Switched both curl calls to `-fsSL` so they fail loudly on non-2xx
responses or redirect loss instead of silently writing empty files.
2026-04-19 20:34:51 +05:30
Atharva Deosthale 56165ee3d9 add claude plugin to static sdk 2026-04-19 18:39:19 +05:30
Chirag Aggarwal d86258a6f6 fix: restore runtime guards and widen types missed by PHPStan cleanup
Three follow-ups from CI that the level-4 pass got wrong:

1. `account.php` / `users.php`: `Document::find()` returns `mixed`
   (specifically `Document|false` in practice), not `Document`. The
   earlier `@var Document $oldTarget` docblocks were lies, and the
   runtime `instanceof Document` guards were load-bearing — removing
   them caused `Call to a member function isEmpty() on false` 500s
   on the `PATCH /v1/users/:id/email` and `/phone` endpoints (and the
   analogous `/v1/account/email`, `/v1/account/phone` flows). Dropped
   the misleading `@var` docblocks and restored
   `$oldTarget instanceof Document && !$oldTarget->isEmpty()`.

2. `Installer/Runtime/Config::setEnabledDatabases()` is a boundary
   that actually takes arbitrary user/compose input — not a trusted
   `string[]`. The `is_string($v)` filter was covering for that, and
   `ConfigTest::testSetEnabledDatabasesFiltersInvalid` explicitly
   asserts it. Widened the PHPDoc to `array<mixed>` and restored
   `is_string($v) && $v !== ''` in the filter.

3. `OAuth2/Apple::getAppSecret()` wrapped `json_decode` in a
   `try/catch (\Throwable)` — but `json_decode` without
   `JSON_THROW_ON_ERROR` returns `null` on failure, it doesn't throw.
   PHP 8.3's PHPStan flagged the catch as dead (PHP 8.5 didn't, which
   is why it slipped through locally). Replaced with
   `if (!\is_array($secret)) throw`, which preserves the original
   "invalid secret" guard.
2026-04-19 17:52:51 +05:30
Chirag Aggarwal d2230f8fe7 chore: bump PHPStan to level 4 and fix all new errors
Raises `phpstan.neon` level from 3 to 4 and fixes the 549 new errors
that level 4 surfaces across 157 files. Fixes are root-cause — no
`@phpstan-ignore`, no `@var` casts, no baseline entries, no widened
types. A handful of latent bugs were fixed along the way:

- `app/controllers/general.php`: path-traversal guard was negating
  `\substr(...)` before the strict comparison (`!\substr(...) === $base`
  was always `false === $base`). Rewritten as `\substr(...) !== $base`.
- `src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php`
  and `.../TablesDB/Logs/XList.php`: were importing the raw Matomo
  `DeviceDetector` (whose `getDevice()` returns `?int`) but treating the
  result as an array with `deviceName/deviceBrand/deviceModel` keys.
  Swapped to `Appwrite\Detector\Detector`, matching the wrapper already
  used a few lines below for `$os`/`$client`.
- `src/Appwrite/Platform/Modules/Functions/Workers/Builds.php`: a match
  key was checking `$resourceKey === 'functions'` when `$resourceKey`
  is `'functionId'|'siteId'` — always false. Switched to the intended
  `$resource->getCollection() === 'functions'` check.
- `src/Appwrite/OpenSSL/OpenSSL.php`: `encrypt()` return type tightened
  to `string|false` to match `openssl_encrypt`; this lets callers'
  `=== false` error handling remain meaningful.
- `app/controllers/api/messaging.php`: removed a dead
  `array_key_exists('from', [])` branch in the Msg91 provider (empty
  array literal; branch was unreachable).

Large cleanup categories across the 549 fixes:
- Removed redundant `?? default` on array offsets and expressions that
  PHPStan now knows are non-nullable.
- Removed unreachable statements (mostly `return;` after `throw` or
  `markTestSkipped()`).
- Removed redundant `is_array`/`is_string`/`is_bool`/`instanceof` checks
  on already-narrowed types.
- Added `default =>` arms (or throwing arms) to non-exhaustive matches
  on `string`/`mixed` input.
- Removed dead `$document === false` branches where method return types
  were tightened to non-nullable `Document`.
- Removed unused properties (`$version` on Etsy/Zoom OAuth2, `$paths` on
  Installer State, `$source` on MigrationsWorker, `$account2` on two
  GraphQL auth tests), unused traits (`ApiVectorsDB`, `DatabaseFixture`),
  and an unused `cleanupStaleExecutions` task method.
- Replaced `assertTrue(true)` and redundant `assertIsArray`/`assertIsString`/
  `assertNotNull` assertions with `addToAssertionCount(1)` or
  `assertNotEmpty` where the runtime type was already known.
2026-04-19 17:31:20 +05:30
Matej BačoandClaude Opus 4.7 69d53cb2d4 Remove unused email template list response model
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:03:36 +02:00
Matej Bačo 447375dcbf Fix tests 2026-04-19 10:53:11 +02:00
Matej Bačo afab349a77 self review fixes 2026-04-19 10:43:57 +02:00
Matej Bačo 2a95cfd5a3 Final template API rework 2026-04-19 10:35:57 +02:00
Matej BačoandClaude Opus 4.7 8a1f8c71b2 Temporary removal of listEmailTemplates
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:15:48 +02:00
ArnabChatterjee20k 2793bcac38 updated 2026-04-18 23:11:04 +05:30
ArnabChatterjee20k 13f48797d4 added project region 2026-04-18 23:01:41 +05:30
Matej Bačo 0a71efc244 improve test coverage 2026-04-18 11:23:53 +02:00
Matej Bačo fcc7a56f4a Fix list endpoint 2026-04-18 11:01:26 +02:00
Luke B. SilverandGitHub 0aab3e9a43 Merge pull request #11941 from appwrite/fix/avif
fix: include project ID in storage preview cache key
2026-04-17 20:28:11 +01:00
Matej Bačo be5eeb1aba Fix failing tests 2026-04-17 19:50:34 +02:00
loks0nandClaude Sonnet 4.6 08b43dce50 fix: ksort after project injection to keep cache key order stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:45:00 +01:00
loks0nandClaude Sonnet 4.6 ad3bdee6c1 fix: include project ID in storage preview cache key
Cache key never included the project ID, so two projects with the same
bucketId, fileId, and transform params would share a cache key. On a
cache hit, Appwrite re-validates the bucket from the cached resourceType
(another project's bucket), which doesn't exist in the requesting
project's DB, throwing storage_bucket_not_found.

Fix: add 'project' to cache.params on the preview route (covers query
param case) and fall back to the X-Appwrite-Project header in
cacheIdentifier() for authenticated requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:34:13 +01:00
Matej Bačo e00efaea68 Fix bug 2026-04-17 18:15:49 +02:00
Luke B. SilverandGitHub be214f7968 Merge pull request #11940 from appwrite/fix/avif
fix: do not cache error responses for storage preview, bump utopia-php/image to 0.8.5
2026-04-17 17:03:29 +01:00
Matej Bačo 64d182ac6a Add tests for templates 2026-04-17 17:49:20 +02:00
Matej Bačo bb38bf4248 Improve code quality 2026-04-17 17:42:46 +02:00
loks0nandClaude Sonnet 4.6 956285d522 fix: do not cache error responses for storage preview, bump utopia-php/image to 0.8.5
Cache write hook now checks HTTP status code before writing to prevent
failed AVIF (or any other) conversions from poisoning the cache.
Bumps utopia-php/image to 0.8.5 which fixes AVIF/HEIC output by using
native Imagick instead of the deprecated magick convert shell command.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:37:25 +01:00
Matej Bačo dc704fdb51 Improved xlist endpoint 2026-04-17 17:27:19 +02:00
Matej Bačo e388d2f6a3 List tempaltes endpoint 2026-04-17 17:22:55 +02:00
Aditya OberaiandGitHub fc62ef2fcc Merge pull request #11926 from appwrite/update-react-admin-template
Update React Admin template metadata
2026-04-17 20:32:45 +05:30
Matej Bačo b01ec03723 Fix analyze bug 2026-04-17 17:01:27 +02:00
Matej Bačo 489b2c4e21 Add new scopes 2026-04-17 16:45:04 +02:00
Matej Bačo 1a46fc2006 Move template APIs under project API 2026-04-17 16:43:17 +02:00
Luke B. SilverandGitHub c1b7aff2d9 Merge pull request #11934 from appwrite/feat/build-timeout
feat: use buildTimeout from message payload in build worker
2026-04-17 15:07:27 +01:00
loks0nandClaude Sonnet 4.6 7df1814203 refactor: rename buildTimeout to timeout in payload and buildDeployment param
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:26:38 +01:00
loks0nandClaude Sonnet 4.6 8f39783d7a refactor: remove jwtExpiry alias, use timeout directly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:26:38 +01:00
loks0nandClaude Sonnet 4.6 4043153df3 fix: pass buildTimeout as parameter to buildDeployment to fix PHPStan error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:26:38 +01:00
loks0nandClaude Sonnet 4.6 9765c7f0e3 feat: use buildTimeout from message payload in build worker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:26:38 +01:00
Matej BačoandGitHub eddd159af8 Merge pull request #11932 from appwrite/feature/remove-/status-from-project-paths-upgrade-to-platform-0
Remove /status from project endpoint paths; upgrade to platform 0.13
2026-04-17 15:14:58 +02:00
ArnabChatterjee20kandGitHub 17de886296 Merge pull request #11936 from appwrite/realtime-time-metric
Realtime time metric
2026-04-17 18:31:51 +05:30
Matej Bačo 27b0e48296 Remove Status suffix from project event names
- project.updateServiceStatus → project.updateService
- project.updateProtocolStatus → project.updateProtocol
2026-04-17 14:53:59 +02:00
ArnabChatterjee20k 6b2054d0b5 Merge remote-tracking branch 'origin/realtime-time-metric' into realtime-time-metric 2026-04-17 18:02:18 +05:30
ArnabChatterjee20k df0f7ba581 added bucket boundary 2026-04-17 18:02:04 +05:30
Matej Bačo c484c487a9 Update tests 2026-04-17 13:19:20 +02:00
Matej Bačo 47f3ab930b Remove /status from project paths; Upgrade to platform 0.13 2026-04-17 13:14:34 +02:00
Matej BačoandGitHub c8c3c68b0e Merge pull request #11912 from appwrite/feat-fallback-email-template
Feat: Fallback email custom template
2026-04-17 12:49:16 +02:00
ArnabChatterjee20kandGitHub facae65f08 Merge pull request #11927 from appwrite/realtime-time-metric
Added delay metric
2026-04-17 16:16:16 +05:30
ArnabChatterjee20kandGitHub ef6711e317 Merge branch '1.9.x' into realtime-time-metric 2026-04-17 16:00:18 +05:30
Matej BačoandGitHub e06b06a21b Merge branch '1.9.x' into feat-fallback-email-template 2026-04-17 11:53:40 +02:00
Matej Bačo c97dd78335 Fix tests 2026-04-17 11:40:05 +02:00
Matej Bačo bf9bb22ac5 New tests 2026-04-17 11:30:24 +02:00
Matej Bačo 1b826df8f9 Non-URL locale to allow optional 2026-04-17 11:24:59 +02:00
Matej Bačo 11f23fdcfa Rework email templates PR after discussions 2026-04-17 10:52:21 +02:00
ArnabChatterjee20k b5ec92964c updated telemetry 2026-04-17 14:08:42 +05:30
ArnabChatterjee20k 71b74e21a3 added delay metric 2026-04-17 13:36:48 +05:30
Aditya Oberai 1e797b3f01 Update React Admin template metadata 2026-04-16 17:00:28 +00:00
Jake BarnbyandGitHub f1b2dd7335 Merge pull request #11925 from appwrite/atharva/compose-fixes-appwrite
Self hosted installer compose fixes
2026-04-17 00:21:50 +12:00
Chirag AggarwalandGitHub 50c379c5c3 Merge pull request #11924 from appwrite/feat/specs-provider-repo-list-discriminator 2026-04-16 16:57:37 +05:30
Atharva Deosthale 463e5acf50 compose fixes 2026-04-16 16:57:19 +05:30
Chirag Aggarwal 807e8bec8b feat(specs): add discriminator for provider repository list response union
Add ProviderRepositoryFrameworkList and ProviderRepositoryRuntimeList
model classes with conditions and type field so the listRepositories
endpoint's oneOf response gets a discriminator on the type property.
2026-04-16 16:29:42 +05:30
Harsh MahajanandGitHub f167049b51 Merge branch '1.9.x' into feat-add-telemetry-for-ss-success-rates 2026-04-16 15:23:45 +05:30
harsh mahajan 93b9500a95 align it with cloud pattern 2026-04-16 15:22:45 +05:30
Chirag AggarwalandGitHub 935c1e40eb Merge pull request #11921 from appwrite/feat/specs-discriminator-unions 2026-04-16 14:03:54 +05:30
Chirag Aggarwal e472d98fe3 Revert "refactor(specs): rename x-propertyNames/x-mapping to x-discriminator-properties/x-union-typemap"
This reverts commit 05d70f8826.
2026-04-16 13:55:36 +05:30
Matej Bačo 7fe65eec57 Restruture endpoints 2026-04-16 10:23:31 +02:00
Matej Bačo 4cf375de6d Re-add removed test 2026-04-16 10:17:08 +02:00
Matej Bačo 19d0eb66c0 Fix tests 2026-04-16 10:09:38 +02:00
Chirag Aggarwal 05d70f8826 refactor(specs): rename x-propertyNames/x-mapping to x-discriminator-properties/x-union-typemap 2026-04-16 13:32:05 +05:30
Chirag Aggarwal 98ec9e45c4 fix(specs): narrow Detection type enum to each subclass's own value
Each Detection subclass now declares only its own type value in the enum
rather than sharing the full ['runtime', 'framework'] list. This prevents
SDK validators from accepting invalid values on concrete models.
2026-04-16 13:16:13 +05:30
Chirag Aggarwal 6dc17c91bc trigger greptile 2026-04-16 13:08:14 +05:30
Chirag Aggarwal 1493b7b8a6 feat(specs): unified discriminator with compound support and algo conditions
Unify getDiscriminator to produce a single discriminator object for both
single-key and compound cases. Single-key returns standard {propertyName,
mapping}. Compound falls back to extending the object with x-propertyNames
and x-mapping for multi-property discrimination.

Simplify call sites: OpenAPI3 uses 'discriminator', Swagger2 uses
'x-discriminator' — no more split keys.

Add conditions to all 7 Algo models (AlgoArgon2, AlgoBcrypt, AlgoMd5,
AlgoPhpass, AlgoScrypt, AlgoScryptModified, AlgoSha) to enable
discriminator generation for hashOptions unions.
2026-04-16 13:02:57 +05:30
Chirag Aggarwal 965836c8b4 fix(specs): use swagger discriminator extension mapping 2026-04-16 12:28:53 +05:30
Chirag Aggarwal 4545989c91 fix(specs): remove type rule from list models, keep only on specific models 2026-04-16 12:22:37 +05:30
Chirag Aggarwal b71d42d226 fix(specs): rename getDisciminator typo and extract shared model resolution
Fix misspelled method name (getDisciminator -> getDiscriminator) across
Format, OpenAPI3, and Swagger2. Extract duplicated model-resolution
lambda into Format::resolveModels(). Fix copy-pasted descriptions in
ProviderRepository list models.
2026-04-16 11:29:16 +05:30
Chirag Aggarwal 945cdb3a99 refactor(specs): inline model resolution 2026-04-16 11:16:25 +05:30
Chirag Aggarwal a0db023860 refactor(specs): simplify discriminator resolution 2026-04-16 11:15:08 +05:30
Chirag Aggarwal 6a7280e7dd refactor(specs): inline discriminator condition checks 2026-04-16 11:12:43 +05:30
Chirag Aggarwal 680cb04de7 feat(specs): add discriminators for polymorphic responses 2026-04-16 11:07:07 +05:30
Damodar LohaniandGitHub 6e50ed050d Merge pull request #11787 from appwrite/CLO-4175-allow-delete-with-memberships
Allow deleting user account with active memberships
2026-04-16 08:16:23 +05:45
Damodar Lohani f78b5c6596 Merge remote-tracking branch 'origin/1.9.x' into CLO-4175-allow-delete-with-memberships 2026-04-16 01:16:49 +00:00
Matej Bačo 55001a7daa New integration tests 2026-04-15 19:27:26 +02:00
Matej Bačo 6d2876ab26 New E2E tests 2026-04-15 19:01:35 +02:00
Matej Bačo b510194f00 Expose "worldwide" locale 2026-04-15 18:57:37 +02:00
Matej Bačo 8fd1c5d620 Remove worldwide to not be user-facing 2026-04-15 18:54:18 +02:00
Matej Bačo 590f063694 Remove remaining sms leftover 2026-04-15 18:40:29 +02:00
Matej Bačo 90e1433878 Fix agent mistake 2026-04-15 18:38:08 +02:00
Matej Bačo 2b42487198 Linter fix 2026-04-15 18:30:06 +02:00
Matej Bačo 53ed9462bd More cleanup of sms templates 2026-04-15 18:29:43 +02:00
Matej Bačo 0da185e689 Refactor fixes 2026-04-15 18:17:55 +02:00
Matej Bačo dc39af50a1 Support for worldwide fallback custom template for all project emails 2026-04-15 18:05:46 +02:00
Matej Bačo 6da132db46 Remove SMS templates and support null locale for mail templates 2026-04-15 18:05:27 +02:00
Matej BačoandGitHub b2a02e3807 Merge pull request #11909 from appwrite/fix-protocol-exception
Fix: protocol & service exception
2026-04-15 16:59:49 +02:00
Matej Bačo e7f78b3e01 Fix shutdown event errors 2026-04-15 15:29:05 +02:00
Harsh MahajanandGitHub 682a56c03c Merge branch '1.9.x' into feat-add-telemetry-for-ss-success-rates 2026-04-15 18:46:07 +05:30
Matej Bačo 7376c5b517 Fix protocol endpoint causing InvalidArgumentException 2026-04-15 15:09:40 +02:00
harsh mahajan 730c62bda6 remove usage stats 2026-04-15 18:35:09 +05:30
ArnabChatterjee20kandGitHub db84c79802 Merge pull request #11907 from appwrite/update-events-not-firing
added backward compat
2026-04-15 18:32:21 +05:30
ArnabChatterjee20k 6ba810a4da fix unit tests 2026-04-15 17:49:33 +05:30
ArnabChatterjee20k 6d9b787816 updated string replacement 2026-04-15 17:38:21 +05:30
ArnabChatterjee20k 7b8fb409b1 added database filtering 2026-04-15 17:33:57 +05:30
ArnabChatterjee20k 1fb78115e8 added backward compat 2026-04-15 17:23:18 +05:30
harsh mahajan 9567f1f4e8 Merge branch 'origin/1.9.x' into feat-add-telemetry-for-ss-success-rates 2026-04-15 17:21:32 +05:30
Chirag AggarwalandGitHub 49c93c635d Merge pull request #11851 from appwrite/chore-migrate-audits-certificates-screenshots-to-publishers 2026-04-15 15:13:18 +05:30
Chirag AggarwalandGitHub 5010cdedbb Merge pull request #11903 from appwrite/fix/ci-mongodb-retry 2026-04-15 14:33:15 +05:30
Chirag AggarwalandGitHub 13413c05c3 Merge pull request #11880 from appwrite/fix/graphql-coroutine-safe-response 2026-04-15 14:14:57 +05:30
Chirag Aggarwal dc185be836 collapse 2026-04-15 13:52:20 +05:30
Chirag AggarwalandGitHub 29be9b6019 Merge branch '1.9.x' into chore-migrate-audits-certificates-screenshots-to-publishers 2026-04-15 13:26:52 +05:30
ArnabChatterjee20kandGitHub 1247b1c719 Merge pull request #11852 from appwrite/docsdb-fixes
Docsdb fixes
2026-04-15 13:18:41 +05:30
harsh mahajan 23233d0dcb add try catch 2026-04-15 11:59:02 +05:30
harsh mahajan 37540aa4f5 addressed comments 2026-04-15 11:52:39 +05:30
harsh mahajan db0a971384 feat(sites): add screenshot success telemetry to usage stats 2026-04-15 11:43:53 +05:30
Chirag AggarwalandGitHub 445e86361c Merge pull request #11904 from appwrite/fix/sdk-push-no-force 2026-04-15 10:27:40 +05:30
Chirag Aggarwal 80197b566c fix: replace force-push with regular push in SDK release task
The SDK push task used `git push --force-with-lease` which fails on
repos with branch protection rules that disallow force pushes. Instead,
checkout the existing remote dev branch and commit on top of it so a
regular push is always a fast-forward.
2026-04-15 10:22:50 +05:30
Chirag Aggarwal 8671533878 fix: remove orphaned docblock from deleted test 2026-04-15 10:13:37 +05:30
Chirag Aggarwal f51f02375a test: remove flaky concurrent session race condition test
testEmailPasswordSessionNotCorruptedByConcurrentRequests relies on
timing-sensitive curl_multi orchestration with hardcoded delays to
reproduce a cache race window. This makes it inherently flaky in CI
where resource pressure shifts the timing unpredictably.
2026-04-15 09:52:32 +05:30
Chirag Aggarwal d40df613de fix: run ProjectWebhooks tests sequentially in CI
ProjectWebhooks tests have shared state dependencies (e.g. index
creation must complete before assertions). Running with --functional
(parallel methods) causes flaky failures where indexes are still
'processing' instead of 'available'.
2026-04-15 09:51:26 +05:30
ArnabChatterjee20kandGitHub ebc3febc38 Merge branch '1.9.x' into docsdb-fixes 2026-04-15 09:42:05 +05:30
Chirag Aggarwal e77bfae091 fix: add restart policy to MongoDB container for flaky CI starts
MongoDB's official Docker entrypoint uses a two-phase startup: a
temporary mongod for user/db init, then the real mongod. Under CI
resource pressure the port may not be released between the two
phases, causing mongod to exit with code 48 (address already in use).

Adding restart: on-failure:3 lets Docker handle the transient failure
natively. On restart the data directory already exists so the
entrypoint skips the two-phase init entirely, avoiding the race.
2026-04-15 09:19:02 +05:30
Chirag Aggarwal f9efd803c1 refactor: remove unnecessary IIFE wrappers in resolver methods 2026-04-15 08:55:00 +05:30
Chirag Aggarwal 54d6470163 refactor: move acquire/release into ResolverLock as instance methods 2026-04-15 08:51:14 +05:30
Chirag Aggarwal 50640b5bb9 refactor: inline createResolverResponse — only called once 2026-04-15 08:50:14 +05:30
Chirag Aggarwal 4d4ba508ef fix: use spl_object_hash for lock keys instead of WeakMap
Replace WeakMap with a plain array keyed by spl_object_hash($utopia)
as suggested in review. Entry is cleaned up in the finally block to
prevent leaks.
2026-04-15 08:47:41 +05:30
Chirag Aggarwal 4f2f9fedfa fix: resolve merge conflict keeping both lock and route restore
Merge conflict in Resolvers.php between the coroutine lock
(fix/graphql-coroutine-safe-response) and the otel route restore
(fix-gql-route-reset from 1.9.x). Both changes are needed:
the lock serialises concurrent resolvers while the route restore
prevents otel span clobbering.
2026-04-15 08:40:12 +05:30
Matej Bačo 732538504d Fix backwards compatibility 2026-04-14 17:42:26 +02:00
Matej Bačo d3dcbfe567 Leftover chaneges, 2026-04-14 17:20:22 +02:00
Matej Bačo 905d2a8eaa Fix tests 2026-04-14 17:20:03 +02:00
Matej Bačo b5e46c1a60 E2E integration tests 2026-04-14 17:09:37 +02:00
Matej Bačo a3a8ad88fe formatting fix 2026-04-14 17:07:06 +02:00
Matej Bačo 9d6428d5d5 Improve tests 2026-04-14 17:06:57 +02:00
Matej Bačo d7f8ca3f01 Improve endpoint quality 2026-04-14 17:04:22 +02:00
Matej Bačo 56bcc0d09f Fix tests 2026-04-14 17:01:56 +02:00
Matej Bačo 8b0c60d2f9 Fix bugs 2026-04-14 17:01:46 +02:00
Matej Bačo ffdfe4bbf8 Add new tests 2026-04-14 16:55:39 +02:00
Matej Bačo 556ca10ed9 Register endpoints 2026-04-14 16:52:38 +02:00
Matej Bačo 193beb76fe add SMTP endpoints 2026-04-14 16:50:07 +02:00
Chirag AggarwalandGitHub 8fcba8b5c9 Merge pull request #11893 from appwrite/codex/disable-graphql-functional 2026-04-14 19:19:14 +05:30
Jake BarnbyandGitHub 6c8b34c230 Merge pull request #11885 from appwrite/fix-gql-route-reset
(fix): reset route to avoid clobbering otel
2026-04-15 01:26:02 +12:00
Chirag Aggarwal b2884ddb88 Use audit message context helper 2026-04-14 18:23:24 +05:30
Chirag Aggarwal 82798fa5a3 Simplify audit message construction 2026-04-14 18:18:25 +05:30
Shimon NewmanandGitHub 377ade3c44 Merge pull request #11892 from appwrite/schedule-functions-debug-2
Schedule functions debug
2026-04-14 15:32:29 +03:00
Shimon NewmanGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
71533aaaf3 Update app/cli.php
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-14 15:17:37 +03:00
shimon ccbe7e5d3f Merge branch '1.9.x' of github.com:appwrite/appwrite into schedule-functions-debug-2 2026-04-14 15:11:56 +03:00
Chirag AggarwalandGitHub bea7739d7e Merge branch '1.9.x' into codex/disable-graphql-functional 2026-04-14 17:21:55 +05:30
Jake BarnbyandGitHub 76320d652a Merge pull request #11887 from appwrite/fix/composer-audit-graphql-php
Upgrade graphql-php to v15.31.5
2026-04-14 23:46:45 +12:00
Chirag Aggarwal 6d5968e2ea ci: disable functional GraphQL e2e 2026-04-14 16:51:46 +05:30
Jake Barnby fd8fedca18 (refactor): Remove shared tables V1/V2 versioning 2026-04-14 22:51:36 +12:00
shimon 70c380fa36 feat: add tracing support for execution events in log and worker classes 2026-04-14 13:36:29 +03:00
shimon 512a7ae2bd feat: enhance function scheduling with tracing support 2026-04-14 13:36:19 +03:00
Chirag AggarwalandGitHub 49d3cd980f Merge branch '1.9.x' into fix/composer-audit-graphql-php 2026-04-14 15:07:22 +05:30
Luke B. SilverandGitHub 39a396810a Merge pull request #11881 from appwrite/feat/45-min-build-timeout
feat: increase default build timeout to 45 minutes
2026-04-14 09:28:29 +01:00
Jake BarnbyandGitHub c52ca65905 Revert "Safe delete shared tables v1" 2026-04-14 19:15:41 +12:00
Jake BarnbyandGitHub 1d307178bb Merge pull request #11691 from appwrite/delete-project-shared-table-v1
Safe delete shared tables v1
2026-04-14 18:00:00 +12:00
fogelito c3ed2ff6ce Merge branch '1.9.x' of https://github.com/appwrite/appwrite into delete-project-shared-table-v1 2026-04-14 08:05:44 +03:00
Chirag Aggarwal efadf17bfe Fix GraphQL 15 static analysis 2026-04-14 10:26:59 +05:30
Chirag Aggarwal bcfec8d5de Align graphql version pinning style 2026-04-14 09:35:25 +05:30
Chirag Aggarwal 4b2e22d9da Fix graphql-php audit vulnerability 2026-04-14 09:27:14 +05:30
ArnabChatterjee20k 45e22e2243 Merge remote-tracking branch 'origin/1.9.x' into docsdb-fixes 2026-04-13 22:44:07 +05:30
Matej BačoandGitHub 0a21f3b139 Merge pull request #11884 from appwrite/fix-webhook-api-security
Fix: Webhook API secret rotation and security
2026-04-13 15:52:55 +02:00
Chirag AggarwalandGitHub 5d53685976 Merge branch '1.9.x' into chore-migrate-audits-certificates-screenshots-to-publishers 2026-04-13 19:11:43 +05:30
Chirag AggarwalandGitHub d869c39783 Merge branch '1.9.x' into fix/graphql-coroutine-safe-response 2026-04-13 19:02:06 +05:30
Chirag Aggarwal fe02964ebd fix: finalize graphql coroutine response isolation 2026-04-13 19:01:20 +05:30
Jake Barnby cc8eb62c83 (chore): rename 2026-04-14 01:15:06 +12:00
Luke B. SilverandGitHub b1ad6ea87c Merge pull request #11883 from appwrite/fix/large-execution-payload
fix: trim execution queue payload to project ID only
2026-04-13 14:13:43 +01:00
Jake Barnby d52d6c0bf0 (fix): reset route to avoid clobbering otel 2026-04-14 01:13:32 +12:00
Chirag AggarwalandGitHub 86cfea0edb Merge branch '1.9.x' into chore-migrate-audits-certificates-screenshots-to-publishers 2026-04-13 18:41:52 +05:30
Matej Bačo db406b0a27 Fix tests 2026-04-13 15:08:20 +02:00
Matej Bačo 2585518e33 Fix bugs 2026-04-13 15:08:13 +02:00
Chirag Aggarwal a1342b4b9d fix: update audit context usage 2026-04-13 18:32:38 +05:30
Matej Bačo c9fceb870c Fix folder structure 2026-04-13 14:57:24 +02:00
Matej Bačo 9f1ec356d1 Formatting fix 2026-04-13 14:53:25 +02:00
Matej Bačo 28d285d5c5 Improved tests for webhook edge cases 2026-04-13 14:53:06 +02:00
loks0nandClaude Sonnet 4.6 a941d8b855 fix: trim execution queue payload to project ID only
The v1-executions queue was serialising the full project document
(OAuth providers, webhooks, keys, auths config, permissions, etc.)
into every message. The worker DI system already re-fetches the
complete project from the platform database using only the project ID,
so the full document was wasted bytes.

Override trimPayload() in the Execution event class to include only
$id in the project stub, reducing message size significantly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:52:09 +01:00
Chirag Aggarwal d40a355d9d refactor: simplify audit event context 2026-04-13 18:21:39 +05:30
Matej Bačo 3263133e5f Implement secure webhook interfaces 2026-04-13 14:50:06 +02:00
Chirag Aggarwal f4f5494b85 fix: isolate graphql resolver responses 2026-04-13 17:48:48 +05:30
Chirag Aggarwal fc0fd2f6ac fix: scope graphql resources to resolver coroutine 2026-04-13 17:10:38 +05:30
Chirag Aggarwal 70a75c2e7b fix: scope graphql resolver lock to request 2026-04-13 16:47:33 +05:30
Chirag Aggarwal 6bc2168e29 fix: isolate graphql resolver request state 2026-04-13 16:34:27 +05:30
fogelito a3ba66c90a Merge branch '1.9.x' of https://github.com/appwrite/appwrite into delete-project-shared-table-v1
# Conflicts:
#	composer.lock
2026-04-13 13:29:03 +03:00
ArnabChatterjee20kandGitHub c24d724000 Merge pull request #11749 from appwrite/db-workers-memory
added reset in db worker for queue for realtime
2026-04-13 15:47:54 +05:30
Luke B. SilverGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
df5ccc10ad Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-13 10:48:20 +01:00
loks0nandClaude Sonnet 4.6 2807d6cd9a feat: increase default build timeout to 45 minutes
Raises _APP_COMPUTE_BUILD_TIMEOUT default from 900s (15 min) to
2700s (45 min) to support longer-running builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:57:27 +01:00
ArnabChatterjee20kandGitHub 20e2f2284f Merge branch '1.9.x' into db-workers-memory 2026-04-13 13:43:14 +05:30
Matej BačoandGitHub 7a0d69c826 Merge pull request #11839 from appwrite/feat-services-protocols-apis
Feat: services protocols public apis
2026-04-13 09:38:17 +02:00
Matej Bačo feedec80f2 Merge branch '1.9.x' into feat-services-protocols-apis 2026-04-13 09:17:09 +02:00
Damodar LohaniandClaude Opus 4.6 18b1c344e6 fix: serialize batched GraphQL queries for coroutine safety
When Swoole coroutine hooks are enabled (SWOOLE_HOOK_ALL), batched
GraphQL queries execute in parallel coroutines that share a single
Response object. Concurrent coroutines interleave writes to the
shared response payload, causing data mixing between queries.

Cloning the response is not viable because cookies/headers written
by the action (e.g. session tokens) must reach the real HTTP response.

Instead, serialize the critical section (execute → getPayload) using
a Swoole Channel as a coroutine-safe mutex. This ensures only one
batched query writes to the Response at a time while preserving
cookie/header propagation. The lock is released before resolve/reject
callbacks so downstream processing remains concurrent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:54:15 +00:00
Damodar LohaniandGitHub f4d40a1289 Merge pull request #11879 from appwrite/fix/graphql-batch-sent-reset
fix: reset response sent state between batched GraphQL queries
2026-04-13 11:35:22 +05:45
Damodar LohaniandGitHub 1e65f075e6 Merge branch '1.9.x' into fix/graphql-batch-sent-reset 2026-04-13 11:23:23 +05:45
Jake BarnbyandGitHub 68725d9262 Merge pull request #11860 from appwrite/fix-cache-fallback
(fix): cache fallback
2026-04-13 17:34:51 +12:00
Damodar LohaniandClaude Opus 4.6 5b805d686b fix: reset response sent state between batched GraphQL queries
utopia-php/http 0.34.20 added a guard that skips the action if
$response->isSent() is true. In batched GraphQL requests the resolver
reuses a single Response across all queries — after the first query's
action calls send(), subsequent queries hit the guard, their actions
are skipped, and stale/null payloads are returned.

Add Response::clearSent() to the Appwrite Response subclass (which can
access the protected $sent property from the parent) and call it in
Resolvers::resolve() before each execute(). This ensures each batched
query gets a fresh sent state while keeping the guard active for normal
request paths.

Also bumps utopia-php/http from 0.34.19 to 0.34.20 so CE CI tests
against the same version used by downstream consumers (cloud).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:32:04 +00:00
Chirag AggarwalandGitHub 584acafb1d Merge branch '1.9.x' into feat-services-protocols-apis 2026-04-13 10:45:42 +05:30
Chirag AggarwalandGitHub dce7856b77 Merge pull request #11848 from appwrite/fix/spec-generator-console-pr82 2026-04-13 10:40:47 +05:30
Chirag Aggarwal a6af609317 Remove scopes spec override, now fixed at source in #11839 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 035f6244e1 Revert "fix: require scopes for project keys"
This reverts commit 8deafcaf4d52a59cc2e1b27c7a128e8b7843afa4.
2026-04-13 10:33:46 +05:30
Chirag Aggarwal 723cb1a488 fix: require scopes for project keys 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 815209ebb0 fix: address sdk spec review feedback 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 53c74582fc refactor: simplify request parameter spec overrides 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 78bbe77580 fix: align project sdk spec generation 2026-04-13 10:33:45 +05:30
0c3871a681 fix: pass response to Http::execute() in GraphQL resolver (#11876)
Http::execute() now requires a Response parameter as of utopia-php/http
0.34.20. The GraphQL resolver was only passing route and request,
causing all GraphQL queries to fail with "Internal server error".

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:33:32 +12:00
Chirag AggarwalandGitHub b59eba4ec6 Merge pull request #11874 from appwrite/fix/11763-installer-compose-executor 2026-04-13 08:52:39 +05:30
Damodar LohaniandGitHub c6e32940f4 Merge branch '1.9.x' into CLO-4175-allow-delete-with-memberships 2026-04-13 07:21:38 +05:45
Chirag Aggarwal cb4c97f2ee chore: remove installer compose regression test 2026-04-12 14:11:51 +05:30
Chirag Aggarwal 2ee2ea09a0 fix(installer): sync compose template executor image 2026-04-12 13:56:49 +05:30
Chirag AggarwalandGitHub a31cb67ec5 Merge branch '1.9.x' into chore-migrate-audits-certificates-screenshots-to-publishers 2026-04-11 22:56:10 +05:30
Chirag AggarwalandGitHub 4ec84a147f Merge pull request #11861 from appwrite/fix-edge-pzj-rule-deployment-resource-type-optional
Make rule deploymentResourceType optional for non-deployment rules
2026-04-11 22:08:17 +05:30
Chirag AggarwalandGitHub e583de4650 Merge pull request #11858 from appwrite/fix-cve-2026-40194-phpseclib-bump
Bump phpseclib to 3.0.51 for CVE-2026-40194
2026-04-11 22:07:47 +05:30
Chirag Aggarwal 98af2a5eb3 fix: make rule deploymentResourceType optional 2026-04-11 22:05:01 +05:30
Jake BarnbyandClaude Opus 4.6 e3ad0f85de fix: narrow cache try-catch to avoid swallowing query exceptions
Wrap only cache load/save calls in try-catch instead of the entire
cache block. This prevents OrderException, QueryException, and Timeout
from $find() being caught and retried, which would double DB calls and
hide real query errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:42:47 +12:00
Jake Barnby a26382ac51 (chore): lockfile 2026-04-12 03:25:07 +12:00
Jake Barnby 9ba182d8a0 (fix): cache fallback 2026-04-12 03:22:21 +12:00
Matej Bačo 27fc8058b9 Fix failing tests 2026-04-11 14:19:05 +02:00
Matej Bačo c5bd8c712f Upgrade libs 2026-04-11 11:31:35 +02:00
Matej Bačo a1267b1bff Backwards compatibiltiy tests 2026-04-11 11:16:43 +02:00
Matej Bačo 18d17ea945 Webhook endpoints quality improvements 2026-04-11 11:00:56 +02:00
Matej Bačo fabd9559c4 Tests for backwards compatibility 2026-04-11 10:22:03 +02:00
Matej Bačo ec637d4417 Mark key scopes required 2026-04-11 10:19:14 +02:00
Chirag Aggarwal 5ecd15a5f5 fix: register certificate publisher in cli 2026-04-11 09:07:51 +05:30
Chirag Aggarwal 4523e86b91 fix: bump phpseclib to 3.0.51 2026-04-11 09:01:42 +05:30
Chirag Aggarwal ec5472f1ed chore: remove unrelated queue resources 2026-04-11 08:57:06 +05:30
Chirag AggarwalandGitHub 9ae804f8ae Merge branch '1.9.x' into chore-migrate-audits-certificates-screenshots-to-publishers 2026-04-11 08:49:23 +05:30
Luke B. SilverandGitHub 1ea108c2ce Merge pull request #11854 from appwrite/feat/remove-err
feat: remove error logs
2026-04-10 14:18:33 +01:00
loks0n 0a864e51b8 feat: remove error logs 2026-04-10 14:17:24 +01:00
ArnabChatterjee20k 0cce480592 typo 2026-04-10 17:23:43 +05:30
ArnabChatterjee20k b622b092a8 updated 2026-04-10 17:22:22 +05:30
ArnabChatterjee20k 96fe989f6d update composer dependencies and remove obsolete log classes 2026-04-10 17:15:31 +05:30
Chirag Aggarwal 0de26be6e6 chore: address review feedback 2026-04-10 16:44:24 +05:30
Chirag Aggarwal dc0a5c88b7 refactor: migrate audits certificates screenshots to publishers 2026-04-10 16:44:00 +05:30
Chirag AggarwalandGitHub c6dd7de216 Merge pull request #11850 from appwrite/chore-migrate-selected-queues-to-publishers
Migrate executions, migrations, and stats resources to publishers
2026-04-10 15:13:15 +05:30
Chirag Aggarwal f77a64bff9 chore: address publisher PR nits 2026-04-10 14:00:57 +05:30
ArnabChatterjee20k d13dbae0fe added allowance of empty payload for documentsdb 2026-04-10 13:59:45 +05:30
Luke B. SilverandGitHub f552a1ba15 Merge pull request #11844 from appwrite/feat/storage-cache-spans
feat: add tracing spans for storage file preview timing and cache state
2026-04-10 09:18:42 +01:00
Chirag Aggarwal 7282c5d51f chore: remove unused execution exclusion 2026-04-10 13:25:32 +05:30
Chirag Aggarwal 82ec75d582 chore: address PR review feedback 2026-04-10 13:12:08 +05:30
Chirag Aggarwal 6bf6142667 refactor: migrate selected queues to publishers 2026-04-10 13:02:00 +05:30
Chirag AggarwalandGitHub 938e65cb02 Merge pull request #11831 from appwrite/codex/remove-realtime-http-dependency
Use dedicated connection resources in realtime
2026-04-10 12:54:45 +05:30
ArnabChatterjee20kandGitHub 114de91f48 Merge pull request #11767 from appwrite/realtime-query-message-payload
Realtime query message payload
2026-04-10 12:06:00 +05:30
ArnabChatterjee20k 2e6f3f5c14 typo 2026-04-10 11:13:03 +05:30
ArnabChatterjee20k 7b3d9bae03 updated authorization 2026-04-10 11:04:44 +05:30
Chirag Aggarwal d81a1154e3 refactor: isolate realtime connection resources 2026-04-10 10:19:41 +05:30
Chirag Aggarwal a944c65660 refactor: move worker message resources 2026-04-10 09:43:32 +05:30
Chirag Aggarwal 856046dc82 shrink the size 2026-04-10 09:28:17 +05:30
Chirag Aggarwal 2ca551123d use connection container 2026-04-10 09:25:00 +05:30
Chirag Aggarwal c861d45749 Merge branch '1.9.x' into codex/remove-realtime-http-dependency 2026-04-10 09:02:23 +05:30
loks0nandClaude Sonnet 4.6 4a43969da9 fix: use consistent dot notation for all storage span attribute names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:53:20 +01:00
loks0nandClaude Sonnet 4.6 6fa4122910 fix: rename storage span attributes to use dot notation for ids
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:52:25 +01:00
loks0nandClaude Sonnet 4.6 1d27101770 feat: add tracing spans for storage file preview timing and cache state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:49:57 +01:00
Luke B. SilverandGitHub 9214decc8d Merge pull request #11843 from appwrite/fix/session-mails
fix: set project on mail queue in session mails listener
2026-04-09 18:30:24 +01:00
loks0nandClaude Sonnet 4.6 ee4ae3bd47 fix: set project on mail queue in session mails listener
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:26:07 +01:00
Luke B. SilverandGitHub 53a114e7e4 Merge pull request #11842 from appwrite/fix/storage-cache
fix: storage preview cache misses and stale cache eviction
2026-04-09 17:27:27 +01:00
loks0nandClaude Sonnet 4.6 f2df9cb93a fix: storage preview cache misses and stale cache eviction
Three bugs causing storage preview cache to be ineffective:

1. Cache keys included the `token` auth parameter, so requests using
   resource tokens always generated unique keys and never hit cache.
   Introduced `cache.params` label for routes to opt-in specific params
   into the cache key; preview now declares only the transform params.

2. Cache hits never refreshed `accessedAt` in the DB or the filesystem
   file mtime, because `$response->send()` in the init hook skips the
   shutdown hook. After 30 days the maintenance job evicted still-active
   cache entries, and after the original 30-day filesystem TTL the cache
   file expired — causing periodic full re-renders. The cache-hit path
   now updates both on the APP_CACHE_UPDATE (24h) interval.

3. `updateDocument` in the preview action passed the full file document
   instead of a sparse one when updating `transformedAt`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:05:14 +01:00
Matej Bačo d69726487e PR review fixes 2026-04-09 16:58:42 +02:00
Matej Bačo 5fccb8cc28 Improve tests 2026-04-09 16:57:44 +02:00
Luke B. SilverandGitHub f371237fd5 Merge pull request #11642 from appwrite/feat/mails-listener
feat: extract session alert email into Mails listener
2026-04-09 15:29:16 +01:00
Matej Bačo 21a0d60c98 Fix tests 2026-04-09 16:13:54 +02:00
Matej Bačo 4eb8534294 Fix tests 2026-04-09 16:08:11 +02:00
Matej Bačo c95f905bce New services and protocols tests 2026-04-09 15:58:28 +02:00
Matej Bačo 0293da1e22 Improve test for backwards compatibility 2026-04-09 15:54:00 +02:00
Matej Bačo a4a0c4175d Implement new endpoints in /v1/project for services and protocols 2026-04-09 15:45:06 +02:00
Harsh MahajanandGitHub 9548d18a3e Merge pull request #11838 from appwrite/ix-missing-worker-executions-template
fix(installer): add missing worker-executions service to compose template
2026-04-09 19:06:42 +05:30
Matej Bačo d3c73fbb49 Add endpoints to control protocols and services 2026-04-09 15:34:50 +02:00
Harsh MahajanandGitHub 7928175387 Merge branch '1.9.x' into ix-missing-worker-executions-template 2026-04-09 18:53:39 +05:30
Matej Bačo 8818187740 Introduce req&res filters for 1.9.1 2026-04-09 15:21:58 +02:00
Matej Bačo 75324b24fc Improve skill 2026-04-09 15:21:24 +02:00
Matej Bačo d6d118f4ab Bump version to 1.9.1 2026-04-09 15:19:58 +02:00
Matej Bačo e998739998 Add agent skill to increase patch version 2026-04-09 15:18:26 +02:00
loks0nandClaude Sonnet 4.6 dd29967e99 refactor: tighten Mails listener with guard clauses and lean event
- SessionCreated event now carries only domain data (no isFirstSession)
- Mails listener uses ordered guard clauses, deferring the DB query
  until cheaper checks pass
- Drop $user Document allocation in favour of direct array access
- Inline FileName validator and $smtpEnabled into their use sites
- Extract $isBranded to eliminate duplicate APP_BRANDED_EMAIL_BASE_TEMPLATE check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:01:13 +01:00
loks0nandClaude Sonnet 4.6 e7f5ae9306 fix: remove stale @param platform from SessionCreated docblock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:01:13 +01:00
loks0nandClaude Sonnet 4.6 4133ec99ae feat: extract session alert email into Mails listener
Moves session alert email side effect out of the account controller
into a dedicated `Mails` listener that reacts to a new `SessionCreated`
bus event. The event is now always dispatched on session creation; the
listener owns all conditional logic (first session, sessionAlerts flag,
email-link sessions, user email presence).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:01:13 +01:00
Matej BačoandGitHub 6dd63ee152 Merge pull request #11655 from appwrite/copilot/add-test-for-deleting-partially-uploaded-file
Fix deletion of partially-uploaded (pending) files
2026-04-09 14:58:57 +02:00
ArnabChatterjee20k 912dbda159 updated type 2026-04-09 18:16:09 +05:30
ArnabChatterjee20k 410a050244 updated 2026-04-09 18:04:01 +05:30
ArnabChatterjee20k 9cf45816c2 added triggering stats for messaging based subscription during the start 2026-04-09 17:38:25 +05:30
Harsh MahajanandGitHub 386fc995e6 Update compose.phtml 2026-04-09 17:36:07 +05:30
Matej Bačo d6451b8fad Merge branch '1.9.x' into copilot/add-test-for-deleting-partially-uploaded-file 2026-04-09 14:02:06 +02:00
ArnabChatterjee20kandGitHub 920ddd18e6 Merge branch '1.9.x' into realtime-query-message-payload 2026-04-09 17:24:57 +05:30
Harsh MahajanGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
ff9334ab78 Update app/views/install/compose.phtml
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-09 17:20:55 +05:30
Harsh MahajanandGitHub c494e96f79 Merge branch '1.9.x' into ix-missing-worker-executions-template 2026-04-09 17:18:22 +05:30
harsh mahajan fd78f0f7df fix(installer): add missing worker-executions service to compose template 2026-04-09 17:13:54 +05:30
Chirag AggarwalandGitHub 4ff10dcacc Merge pull request #11837 from appwrite/fix-11795-revert-project-variableid
Revert "fix: make variableId optional in createProjectVariable endpoint"
2026-04-09 16:53:10 +05:30
Chirag Aggarwal 7a995fe759 Revert "Merge pull request #11795 from rathi-yash/fix-11765-global-variable-creation"
This reverts commit 597b20a6cb, reversing
changes made to 20f80ac067.
2026-04-09 16:25:59 +05:30
Damodar LohaniandGitHub 227606baef Merge pull request #11835 from appwrite/fix/map-deprecated-platform-types-in-project-response
fix: map deprecated platform types in project response
2026-04-09 14:23:31 +05:45
Damodar LohaniandClaude Opus 4.6 f315e759f3 fix: map deprecated platform types in subQueryPlatforms filter
The subQueryPlatforms database filter loads platforms as a sub-attribute
when project documents are fetched. Old platform type values stored in
the database (e.g. flutter-android, flutter-ios) were not being mapped
to the new consolidated types before being included in the project
response sent to the frontend/console.

This adds Platform::mapDeprecatedType() to the filter so all platforms
returned as part of a project document have their types mapped
consistently, complementing the existing mapping in the dedicated
platform Get and List endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:31:39 +00:00
Damodar LohaniandGitHub 8a6fba75a5 Merge pull request #11833 from appwrite/fix/deprecated-platform-origin-validation
fix: Add backwards-compat origin validation for deprecated platform types
2026-04-09 13:41:30 +05:45
Damodar LohaniandClaude Opus 4.6 196c76ac70 fix: map deprecated platform types in read endpoints for backwards compatibility
Old platform types stored in the database (e.g. flutter-web, apple-ios,
react-native-android) are now mapped to the new consolidated types (web,
apple, android, windows, linux) before being sent in API responses. This
ensures the response models' $conditions correctly select the right model
for each platform document.

Adds Platform::mapDeprecatedType() as a reusable static method and applies
the mapping in both Get and XList platform endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:47:42 +00:00
Damodar LohaniandClaude Opus 4.6 7f82484436 remove migration changes from V24.php
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:36:58 +00:00
Damodar LohaniandClaude Opus 4.6 db18caf739 fix: add backwards-compatible origin validation for deprecated platform types
Platform::getHostnames() and Platform::getSchemes() only handled the new
consolidated type names, causing "invalid origin" errors for projects still
using old granular types (flutter-*, apple-*, react-native-*, unity) stored
in the database. Add switch fall-through cases for all deprecated type values
and a V24 migration to convert old types to their new equivalents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:31:58 +00:00
Chirag AggarwalandGitHub 6693e28c93 Merge pull request #11830 from appwrite/feat/specs-task-no-http
remove HTTP server bootstrap from specs task
2026-04-09 11:46:37 +05:30
Chirag Aggarwal bf489ce13b Fix requestRoutePath fallback 2026-04-09 11:09:21 +05:30
Chirag Aggarwal 357d6482f9 Remove realtime HTTP app dependency 2026-04-09 10:52:31 +05:30
Chirag Aggarwal eec72a915a inline specs container setup 2026-04-09 10:45:24 +05:30
Chirag Aggarwal ae29e4c424 drop specs task format test 2026-04-09 10:43:39 +05:30
Chirag Aggarwal 05dc264df9 remove specs task HTTP bootstrap 2026-04-09 10:41:04 +05:30
Chirag AggarwalandGitHub 597b20a6cb Merge pull request #11795 from rathi-yash/fix-11765-global-variable-creation
fix: make variableId optional in createProjectVariable endpoint
2026-04-09 09:34:39 +05:30
Chirag AggarwalandGitHub 6b7d9853f9 Merge branch '1.9.x' into fix-11765-global-variable-creation 2026-04-09 09:24:52 +05:30
Damodar LohaniandGitHub d6f51a96a5 Merge branch '1.9.x' into CLO-4175-allow-delete-with-memberships 2026-04-09 07:32:30 +05:45
Damodar LohaniandGitHub 20f80ac067 Merge pull request #11580 from appwrite/feat-audit-user-type-distinction
feat: distinguish user types in audit logs
2026-04-09 06:55:43 +05:45
My Name 8bfa659120 test: add test case for createProjectVariable without variableId 2026-04-08 15:18:13 -04:00
Yash RathiandGitHub 65e44d76a1 Merge branch '1.9.x' into fix-11765-global-variable-creation 2026-04-08 14:50:58 -04:00
Jake BarnbyandGitHub bb07808661 Merge pull request #11820 from appwrite/fix-ttl-message 2026-04-09 03:39:48 +12:00
Luke B. SilverandGitHub d18550ff54 Merge pull request #11826 from appwrite/fix/cookie-test-hack
fix: use cURL cookie engine for RFC 6265 compliant cookie handling in e2e client
2026-04-08 16:17:04 +01:00
loks0nandClaude Sonnet 4.6 84dc921d41 fix: replace utopia-php/framework with http, fix RFC 6265 cookie handling
utopia-php/framework was the old name for utopia-php/http. Replacing it
with utopia-php/http 0.34.19 which fixes getCookie() to use Swoole's
native cookie store (populated via php_raw_url_decode) instead of
re-parsing the raw Cookie header without URL-decoding.

This fixes a production auth bug where Swoole's setcookie() URL-encodes
base64 session values (+ → %2B, / → %2F, = → %3D) in Set-Cookie headers.
RFC 6265 clients (Dart, Swift) reflect these verbatim; the old getCookie()
returned %2B/%2F/%3D to base64_decode() which produced corrupted output,
rejecting valid sessions.

Also updates the e2e test client to use cURL's built-in RFC 6265 cookie
engine (CURLOPT_COOKIEFILE) instead of parse_str() which silently
URL-decoded values, masking the bug in tests. Adds a cookie roundtrip
assertion to testCreateAccountSession.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:07:46 +01:00
loks0nandClaude Sonnet 4.6 e2d7dd837d fix: use cURL cookie engine instead of parse_str for RFC 6265 compliance
parse_str() URL-decodes cookie values, causing the test client to behave
differently from real clients (Dart, Swift) which store values verbatim
per RFC 6265. This masked a production bug where base64 session values
containing %3D%3D would fail to decode on real devices.

Replaces the manual Set-Cookie header parsing with cURL's built-in cookie
engine (CURLOPT_COOKIEFILE='') and reads cookies via CURLINFO_COOKIELIST,
which stores and returns values verbatim without any decoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:07:46 +01:00
Jake BarnbyandGitHub 9feb204fd5 Merge pull request #11611 from appwrite/feat-x-oauth2-provider 2026-04-09 01:32:31 +12:00
Jake Barnby 3880f181b3 fix(databases): propagate purge parameter to documentsdb updateCollection 2026-04-09 01:19:18 +12:00
Harsh Mahajan 44a37e9e20 Use Exception for X OAuth2 PKCE encryption errors
Align with other OAuth2 adapters that throw base Exception for
configuration and crypto failures instead of RuntimeException.

Made-with: Cursor
2026-04-08 18:41:42 +05:30
Harsh Mahajan e6cfedd340 addressed greptile comment 2026-04-08 18:27:36 +05:30
Harsh Mahajan e4d1178e71 simplified code 2026-04-08 17:56:37 +05:30
Harsh Mahajan 929bdcef25 Merge branch '1.9.x' into feat-x-oauth2-provider 2026-04-08 17:55:00 +05:30
Harsh Mahajan 3f725c6be9 changes 2026-04-08 17:44:49 +05:30
Jake BarnbyGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
990a32dd9e Update src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-08 23:31:55 +12:00
Jake Barnby c5b8ed9cc1 feat(databases): cache list responses without requiring a select query 2026-04-08 23:02:34 +12:00
Matej BačoandGitHub fec573d23b Merge pull request #11821 from appwrite/chore-public-project-api
Chore: Mark project response format sub-formats as public
2026-04-08 12:32:58 +02:00
Matej Bačo a144968d70 Fix formatting 2026-04-08 12:08:32 +02:00
Matej Bačo 6fa0724404 Mark project response format sub-formats as public 2026-04-08 11:52:12 +02:00
Jake Barnby b1ce71e6b0 (chore): fmt 2026-04-08 21:35:49 +12:00
Jake Barnby 939092726c test(databases): add regression for purge=true list cache invalidation 2026-04-08 21:31:42 +12:00
Jake Barnby c6f8599c75 feat(databases): add purge parameter to updateCollection and updateTable 2026-04-08 21:31:38 +12:00
Jake Barnby 91d8519940 refactor(databases): restructure list response cache and clarify ttl description 2026-04-08 21:31:34 +12:00
Matej BačoandGitHub a90f79f1c1 Merge pull request #11650 from appwrite/feat-public-project-keys
Feat: Public keys API
2026-04-08 11:15:04 +02:00
Matej Bačo f880b6e8c3 Fix failing tests 2026-04-08 10:52:20 +02:00
Matej Bačo b8d65326e6 Fix failing tests 2026-04-08 10:34:18 +02:00
Matej Bačo a9fd82e406 New tests 2026-04-08 10:32:20 +02:00
Matej Bačo a8c2491fbb Fix platform scopes 2026-04-08 10:17:48 +02:00
Jake BarnbyandGitHub 7f6486ec80 Merge pull request #11762 from bhardwajparth51/fix-10923-realtime-atomic-payload 2026-04-08 20:16:55 +12:00
Matej Bačo 388cec1737 Merge branch '1.9.x' into feat-public-project-keys 2026-04-08 10:16:22 +02:00
Parth BhardwajandGitHub 2f5a49a37d Merge branch '1.9.x' into fix-10923-realtime-atomic-payload 2026-04-08 13:39:54 +05:30
Matej BačoandGitHub 1f93184c42 Merge pull request #11615 from appwrite/feat-public-platform-api
Feat: public platform API
2026-04-08 10:08:10 +02:00
Jake BarnbyandGitHub 7d9cf48ca3 Merge pull request #11750 from appwrite/bump-database-version2 2026-04-08 20:04:25 +12:00
Matej Bačo eef2a7abdf Fix scopes 2026-04-08 10:01:52 +02:00
Matej Bačo c7a022ba43 Simplify after discussions 2026-04-08 09:54:57 +02:00
Matej Bačo 96a84a8fd7 Merge branch '1.9.x' into feat-public-project-keys 2026-04-08 09:45:15 +02:00
Matej Bačo cea242c66f Merge branch '1.9.x' into feat-public-platform-api 2026-04-08 09:29:54 +02:00
Matej Bačo ce4eb563b3 AI review fixes 2026-04-08 09:29:12 +02:00
Matej Bačo 2307d637fb Revert new patch version 2026-04-08 09:10:02 +02:00
premtsd-codeandGitHub 0aa72aafae Merge branch '1.9.x' into bump-database-version2 2026-04-08 11:32:22 +05:30
Chirag AggarwalandGitHub 6e1f0d57af Merge pull request #11817 from appwrite/fix/specs-unresolved-model
fix: throw RuntimeException for unresolved response models in spec generation
2026-04-08 11:08:30 +05:30
Chirag Aggarwal 62b6ef06e6 fix: add swoole extension to specs CI job 2026-04-08 10:49:50 +05:30
Chirag Aggarwal f5ab593261 fix: make Project model public for server SDK spec generation
The project.updateLabels route uses AuthType::KEY which makes it
available on the server platform, but the Project model had public=false
causing it to be filtered out during spec generation.
2026-04-08 10:47:37 +05:30
Chirag Aggarwal dd4a43b78c fix: throw RuntimeException for unresolved response models in spec generation
Spec generation silently produced a fatal error when a response model
string could not be resolved to a registered model object. Now throws a
clear RuntimeException in both Swagger2 and OpenAPI3 formats, for both
single and array model responses.

Also adds a CI job to run spec generation on every PR so unresolved
models are caught before merge.
2026-04-08 10:41:43 +05:30
premtsd-codeandGitHub c4c56283e5 Merge branch '1.9.x' into bump-database-version2 2026-04-08 10:33:58 +05:30
Chirag AggarwalandGitHub f71a98a527 Merge pull request #11816 from appwrite/fix/email-session-cache-purge
fix: persist session before purging user cache in email/password login
2026-04-08 10:25:19 +05:30
premtsd-codeandGitHub 2258668769 Merge branch '1.9.x' into bump-database-version2 2026-04-08 10:15:53 +05:30
Chirag Aggarwal 6dba407aed test: add E2E test for email/password session cache race condition
Adds testEmailPasswordSessionNotCorruptedByConcurrentRequests which
reproduces the cross-worker Redis cache race that caused 401s after
login. The test fires a login request, waits for it to reach the cache
purge point, then injects concurrent GET /v1/account requests that
re-cache a stale user document. Verifies the new session is immediately
usable.

Fails against the old ordering (purge before create), passes with the
fix (create before purge).
2026-04-08 10:10:16 +05:30
Luke B. SilverandGitHub 7eef845556 Merge pull request #11794 from blueberry-adii/doc-11793-fix-readme-auth-link
fix appwrite auth broken link in readme
2026-04-07 22:15:29 +01:00
loks0nandClaude Sonnet 4.6 7781d377ae fix: persist session before purging user cache in email/password login
Swap the order of createDocument('sessions') and purgeCachedDocument('users')
in the email/password session creation flow. Previously, the cache was purged
before the session was written, opening a race window in Swoole's async
environment where a concurrent account.get() could re-cache the user with no
sessions, causing sessionVerify to fail with a 401. This matches the correct
ordering already used by the token-based flows (magic URL, OTP, phone).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:44:24 +01:00
Chirag AggarwalandGitHub 59bf4663ca Merge pull request #11808 from appwrite/fix/sdk-dev-branch-sync
fix: reset SDK dev branch to base branch before pushing
2026-04-08 00:40:56 +05:30
premtsd-codeandGitHub 6653dbbb92 Merge branch '1.9.x' into bump-database-version2 2026-04-07 20:47:13 +05:30
Prem Palanisamy d7d20ccb29 Remove (int) cast from setTenant in getDatabasesDB same-pool branch 2026-04-07 15:35:20 +01:00
Yash RathiandGitHub 2d54986b09 Merge branch '1.9.x' into fix-11765-global-variable-creation 2026-04-07 10:25:08 -04:00
Matej Bačo 9ea0b2bc2f formatting fix 2026-04-07 15:56:54 +02:00
Matej Bačo 715727853b Fix unit test 2026-04-07 15:56:42 +02:00
Matej Bačo 43d4f709d5 Revert composer changes 2026-04-07 15:00:03 +02:00
Matej Bačo f40050fe6f Revert lockfile changes (failing tests) 2026-04-07 14:51:40 +02:00
Prem Palanisamy 308a534d98 Merge branch 'bump-database-version2' of https://github.com/appwrite/appwrite into bump-database-version2 2026-04-07 13:16:56 +01:00
Prem Palanisamy 35a72c4f08 Remove (int) cast from setTenant in separate-pool branches 2026-04-07 13:10:16 +01:00
Matej Bačo 34dfcba45c Linter fix 2026-04-07 14:08:16 +02:00
Chirag AggarwalandGitHub cb1e31bcd4 Merge pull request #11814 from appwrite/codex/bump-utopia-framework-0-34-18
[codex] Bump utopia-php/framework to 0.34.18
2026-04-07 17:38:06 +05:30
Matej Bačo 3e4e7fc0cd Merge branch '1.9.x' into feat-public-platform-api 2026-04-07 14:08:00 +02:00
ArnabChatterjee20kandGitHub da2444ede2 Merge branch '1.9.x' into realtime-query-message-payload 2026-04-07 17:36:29 +05:30
ArnabChatterjee20k bc224de751 Add userId to connection info in Realtime adapter and simplify userId fetching 2026-04-07 17:35:48 +05:30
Chirag Aggarwal 9403e4d65d Bump utopia-php/framework to 0.34.18 2026-04-07 17:35:32 +05:30
Matej BačoandGitHub 07a49835a6 Merge pull request #11812 from appwrite/chore-migrate-labels-api
Chore: Migrat elabels API
2026-04-07 14:05:31 +02:00
Matej Bačo d32de6f217 Improve tests 2026-04-07 13:30:35 +02:00
Matej Bačo 9b00ce4f1d Add new tests 2026-04-07 13:28:35 +02:00
ArnabChatterjee20k ca62504b5a Enhance realtime message handling to support initial connection payload and improve query subscription logic 2026-04-07 16:55:10 +05:30
Matej Bačo 8f6c8f9d8d Migrate to new endpoint 2026-04-07 13:21:36 +02:00
Matej Bačo 23fcb284a1 Fix backwards QA 2026-04-07 12:46:25 +02:00
premtsd-codeandGitHub ffdd0815a7 Merge branch '1.9.x' into bump-database-version2 2026-04-07 15:56:47 +05:30
Prem Palanisamy 4260324153 Merge branch '1.9.x' into bump-database-version2
Resolve merge conflicts in app/init/resources.php and app/worker.php
caused by the DI container migration (Http::setResource/Server::setResource
to $container->set). Port separate-pool shared tables logic for
getDatabasesDB to the new file locations (request.php and message.php)
with the correct $databaseDSN->getParam('namespace') fix.
2026-04-07 11:25:06 +01:00
Matej Bačo d66813d3cf Fix tests + QA fixes 2026-04-07 12:07:21 +02:00
Chirag AggarwalandGitHub 0ce92da41f Merge pull request #11810 from appwrite/codex/fix-post-merge-test-regressions
[codex] Fix post-merge e2e test regressions
2026-04-07 15:10:53 +05:30
Chirag Aggarwal e8ef4e40d7 fix post-merge e2e test regressions 2026-04-07 15:05:07 +05:30
Chirag AggarwalandGitHub d200b07466 Merge pull request #11809 from appwrite/codex/fix-console-null-route
[codex] Handle null console routes in API middleware
2026-04-07 14:42:08 +05:30
Chirag Aggarwal 92abfb31aa fix null route guard placement 2026-04-07 14:40:18 +05:30
Chirag Aggarwal 6c56eee0f4 test console route not found error type 2026-04-07 14:39:48 +05:30
Chirag Aggarwal 399c37d943 fix console null route handling 2026-04-07 14:33:43 +05:30
Matej Bačo f3acadd53c Fix linter 2026-04-07 10:43:57 +02:00
Matej Bačo 331fcee710 Merge branch '1.9.x' into feat-public-platform-api 2026-04-07 10:43:11 +02:00
Chirag AggarwalandGitHub 951a96ae01 Merge pull request #11564 from appwrite/feat/migrate-di-container
feat: migrate from static Http::setResource() to DI Container
2026-04-07 13:58:10 +05:30
Damodar LohaniandGitHub ed1680837d Merge branch '1.9.x' into feat-audit-user-type-distinction 2026-04-07 14:11:22 +05:45
Chirag Aggarwal 7864a5b9d1 fix: use --force-with-lease on SDK dev branch push
After resetting dev to the base branch, the remote dev may have
diverged history from squash merges. A plain push would be rejected
as non-fast-forward. Using --force-with-lease safely overwrites
the remote since we just fetched.
2026-04-07 12:52:09 +05:30
Chirag Aggarwal f11bd7ce0e fix: reset SDK dev branch to base branch before pushing
The dev branch was being reset to origin/dev, which retains stale
commits after squash merges. This caused recurring merge conflicts
and inflated PR diffs. Using `checkout -B dev <baseBranch>` ensures
dev always starts fresh from the default branch.
2026-04-07 12:50:18 +05:30
Parth BhardwajandGitHub fffc6795a7 Merge branch '1.9.x' into fix-10923-realtime-atomic-payload 2026-04-07 08:49:25 +05:30
Damodar LohaniandGitHub 8442a1e612 Merge branch '1.9.x' into CLO-4175-allow-delete-with-memberships 2026-04-07 06:27:57 +05:45
Shimon NewmanandGitHub ed91549681 Merge pull request #11805 from appwrite/revert-schedule-functions-updates
revert ScheduleFunctions.php updates
2026-04-06 22:38:05 +03:00
shimon 04173600ae revert ScheduleFunctions.php updates 2026-04-06 22:33:18 +03:00
ArnabChatterjee20k d5fe5c34af Validate subscribe payload format in realtime message handling 2026-04-06 17:14:12 +05:30
ArnabChatterjee20k 6bc9adece8 Refactor realtime message handling to send subscriber keys and add comprehensive tests for subscription message upsert behavior 2026-04-06 17:10:57 +05:30
ArnabChatterjee20k 97d46c6273 Remove redundant subscription removal call in realtime message handling 2026-04-06 16:58:08 +05:30
ArnabChatterjee20k 9d78a8e6b6 Add stats tracking for outbound subscription messages in realtime 2026-04-06 16:57:06 +05:30
ArnabChatterjee20k d12a6f5168 Refactor realtime message payload handling for improved validation and parsing 2026-04-06 16:41:42 +05:30
ArnabChatterjee20k 0f47e6ea28 Enhance subscription message documentation for clarity on upsertion behavior 2026-04-06 15:59:22 +05:30
premtsd-codeandGitHub 77ceb15d7d Merge branch '1.9.x' into bump-database-version2 2026-04-06 15:44:40 +05:30
ArnabChatterjee20k 592629587d Remove unused query assertion methods and improve comment clarity in RealtimeQueryBase 2026-04-06 14:07:25 +05:30
ArnabChatterjee20k 187fde4a4e Refactor realtime subscription handling and enhance query validation in tests 2026-04-06 14:05:42 +05:30
Chirag Aggarwal b74d4d45f9 Merge request-scoped cookie resources 2026-04-06 13:21:33 +05:30
Chirag AggarwalandGitHub aa9a58b44d Merge pull request #11799 from appwrite/codex/request-scoped-cookie-domain
Use request-scoped cookie domain resource
2026-04-06 13:18:27 +05:30
Chirag Aggarwal 59a773e9a0 Document migration host local-domain handling 2026-04-06 12:47:06 +05:30
Chirag Aggarwal e3053bb83d Remove dead cookie config defaults 2026-04-06 12:44:48 +05:30
Chirag Aggarwal 1f7fc4bd40 Use request-scoped domain verification 2026-04-06 12:43:05 +05:30
Chirag Aggarwal d1b59ff3f3 Remove unused cookie domain locals 2026-04-06 12:30:48 +05:30
Chirag Aggarwal 221b52bac0 Add request-scoped cookie domain resource 2026-04-06 12:30:25 +05:30
Chirag Aggarwal b8ed30db55 Fix CORS header override for analyze 2026-04-06 12:23:50 +05:30
Chirag AggarwalandGitHub be56317bf2 Merge branch '1.9.x' into feat/migrate-di-container 2026-04-06 12:13:31 +05:30
fogelito 31728c9b72 Update lock 2026-04-06 09:29:58 +03:00
fogelito 9d0fc9e5b1 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into delete-project-shared-table-v1 2026-04-06 09:19:38 +03:00
Chirag AggarwalandGitHub d1549b225b Merge branch '1.9.x' into feat/migrate-di-container 2026-04-06 08:35:58 +05:30
My Name 2cd91f509b fix: make variableId optional in createProjectVariable endpoint 2026-04-05 18:23:58 -04:00
Aditya a06ba57384 fix appwrite auth broken link in readme 2026-04-05 23:39:13 +05:30
Chirag AggarwalandGitHub dffcfe8ee4 Merge branch '1.9.x' into feat/migrate-di-container 2026-04-05 21:16:30 +05:30
Chirag Aggarwal 452440f3c0 fix: use released cli container support 2026-04-05 21:03:17 +05:30
Chirag Aggarwal b236e2546b lock file 2026-04-05 20:10:00 +05:30
Chirag Aggarwal 412d09b801 remove unrelated changes 2026-04-05 20:06:13 +05:30
fogelito a332eb5f32 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into delete-project-shared-table-v1 2026-04-05 11:42:25 +03:00
Damodar LohaniandClaude Opus 4.6 cc82b1a5cf fix: don't promote non-owners on account deletion, leave team orphaned instead
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:15:35 +00:00
Damodar LohaniandClaude Opus 4.6 ba32012744 fix: filter unconfirmed members from owner count, ownership transfer, and primary user transfer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:11:32 +00:00
fogelito 775d21e5ee lock 2026-04-05 10:08:29 +03:00
Damodar LohaniandClaude Opus 4.6 8f6530d1e7 fix: remove unused $userInternalId from closure
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 05:34:04 +00:00
Damodar LohaniandClaude Opus 4.6 4297c70f58 fix: address review feedback — safer orphan approach, veteran ordering, deduplicate transfer
- Remove team deletion for sole owner+sole member case; let orphan teams
  be cleaned up by Cloud's inactive project cleanup (safer, avoids
  accidental data loss)
- Add explicit ordering by $createdAt so the most veteran member gets
  ownership transfer, with limit(1) for clarity
- Remove confirm filter on primary user transfer in membership deletion
  so all members (including unconfirmed) are considered
- Remove redundant ownership transfer from Deletes worker since the API
  controller already handles it before queueing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 05:22:02 +00:00
Damodar LohaniandGitHub f6484294da Merge branch '1.9.x' into feat-audit-user-type-distinction 2026-04-05 08:04:38 +05:45
Damodar LohaniandClaude Opus 4.6 16ed60a5c3 Filter unconfirmed members when transferring team ownership
Prevent unconfirmed (pending invite) members from being promoted to
owner or set as the team's primary user during membership/account
deletion by adding a Query::equal('confirm', [true]) filter to the
relevant findOne queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:02:09 +00:00
Damodar LohaniandClaude Opus 4.6 d831b93934 Allow deleting user account with active memberships
Instead of blocking account deletion when the user has confirmed team
memberships, handle memberships gracefully during deletion:

- Sole owner + sole member: delete the team and queue project cleanup
- Sole owner + other members: transfer ownership to the next member
- Non-owner / multiple owners: no special handling needed (worker cleans up)

Also update the Deletes worker to transfer the team's primary user
reference when removing a deleted user's memberships.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:43:05 +00:00
Chirag AggarwalandGitHub f3b9121a14 Merge branch '1.9.x' into feat/migrate-di-container 2026-04-04 15:57:12 +05:30
bhardwajparth51 2c1813198d Simplify comments in Realtime E2E test 2026-04-03 22:12:24 +05:30
bhardwajparth51 912ea37af6 Address review feedback: Remove redundant Realtime triggers, correctly reorder hydration, and add E2E tests 2026-04-03 22:07:24 +05:30
Parth BhardwajandGitHub ccc74dea74 Merge branch '1.9.x' into fix-10923-realtime-atomic-payload 2026-04-03 20:28:49 +05:30
bhardwajparth51 a6c6a5624a fix: ensure realtime event payload is populated for all atomic subclasses 2026-04-02 21:04:01 +05:30
bhardwajparth51 ee5bb6d73d fix: ensure realtime event payload is populated for atomic operations 2026-04-02 20:47:23 +05:30
ArnabChatterjee20k bfbf180aee Refactor realtime message handling and enhance query validation tests 2026-04-02 18:32:27 +05:30
ArnabChatterjee20k d8a3b53641 Refactor code structure for improved readability and maintainability 2026-04-02 17:35:55 +05:30
Chirag Aggarwal 3018b478ba Fix database transaction and vectors migration flakiness 2026-04-02 17:25:13 +05:30
ArnabChatterjee20k df4dbcf607 updated user roles 2026-04-02 17:02:46 +05:30
Chirag Aggarwal 094fe90499 Merge remote-tracking branch 'origin/1.9.x' into feat/migrate-di-container
# Conflicts:
#	app/worker.php
2026-04-02 16:37:58 +05:30
ArnabChatterjee20k 29b0ebb3bd updated query subscription 2026-04-02 16:28:00 +05:30
ArnabChatterjee20k f0ccd1f586 added message based query payload to realtime 2026-04-02 15:56:56 +05:30
Chirag Aggarwal 4df5f4a18f fix: scope composer lock to intended package updates 2026-04-02 14:06:45 +05:30
Chirag Aggarwal e8bcc94187 fix: keep composer lock scoped to intended updates 2026-04-02 13:59:52 +05:30
Prem Palanisamy a071c715bc Merge remote-tracking branch 'origin/1.9.x' into bump-database-version2 2026-04-02 10:10:03 +02:00
Prem Palanisamy 52981e0164 fix: restore full CI matrix and revert test/endpoint changes to 1.9.x 2026-04-02 10:09:36 +02:00
Chirag Aggarwal e576cd5082 Merge branch '1.9.x' into feat/migrate-di-container 2026-04-02 13:32:31 +05:30
Chirag Aggarwal 30befc6a60 fix: remove strict pool size exception 2026-04-02 11:41:35 +05:30
Chirag Aggarwal d13644a47a fix: make pool sizing runtime-aware 2026-04-02 11:38:19 +05:30
Chirag Aggarwal 4a905a6ac9 Merge branch '1.9.x' into feat/migrate-di-container
Resolve conflicts keeping DI container migration (container->set pattern)
while incorporating 1.9.x fixes: PHPStan unused variable cleanup in
GraphQL Resolvers, (int) casts in Builds.php, and phpstan-baseline removal.
2026-04-02 11:17:32 +05:30
Prem Palanisamy cb9d3869f0 Merge remote-tracking branch 'origin/1.9.x' into bump-database-version2
# Conflicts:
#	src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php
2026-04-02 06:53:05 +02:00
Prem Palanisamy d17ae517d3 fix: replace exists+create with try/catch for vectorsdb metadata init 2026-04-02 06:26:55 +02:00
Prem Palanisamy 25d9891f2b fix: use email probe in JSON export test, format fixes, add requireAdapter skips to Databases VectorsDB/DocumentsDB tests 2026-04-02 04:39:56 +02:00
Damodar LohaniandGitHub a928fcd40f Merge pull request #11754 from appwrite/claude/sync-1.9.x-fix-tests-VoCBt
Fix PHPStan baseline and code quality issues
2026-04-02 08:17:54 +05:45
Claude 2d34301834 fix: add missing userType field to legacy log endpoints
The users.php and messaging.php legacy controllers were missing the
userType field in their log output, creating an inconsistency with the
new audit user type distinction feature. Also adds missing mode field
to users.php logs endpoint.

https://claude.ai/code/session_01J9gKXwbHoLggsGwJi6KUnM
2026-04-02 01:13:01 +00:00
Claude cf7259e060 Merge remote-tracking branch 'origin/1.9.x' into claude/sync-1.9.x-fix-tests-VoCBt 2026-04-02 01:05:56 +00:00
Prem Palanisamy 8c9f9c6f58 fix: inherit project shared tables for separate pools, skip cross-engine migration tests 2026-04-02 02:05:10 +02:00
Prem Palanisamy c322cc3ffe Merge remote-tracking branch 'origin/1.9.x' into bump-database-version
# Conflicts:
#	src/Appwrite/Event/Message/Usage.php
2026-04-01 17:34:13 +02:00
ArnabChatterjee20k cf99269bc5 Add reset method to Realtime class for clearing event state
This new method resets the event state for long-lived worker processes by clearing subscribers, context, and user-related fields, ensuring no stale state affects subsequent triggers.
2026-04-01 18:08:45 +05:30
ArnabChatterjee20k 2a184288f4 removed throw 2026-04-01 18:05:56 +05:30
ArnabChatterjee20k 9237f387fc added reset in db worker for queue for realtime 2026-04-01 17:59:28 +05:30
Matej Bačo b554243447 Merge branch '1.9.x' into feat-public-platform-api 2026-04-01 13:35:46 +02:00
Chirag Aggarwal 15b2ab321e fix: restore pool size validation to prevent silent connection exhaustion
The old guard that threw when workerCount > instanceConnections was
removed during the DI migration, causing pool size to silently floor
to 1. This can lead to connection exhaustion on multi-core hosts.
2026-04-01 16:34:58 +05:30
Chirag Aggarwal 789870b545 fix: preserve multi-value headers like Set-Cookie instead of comma-joining
addHeader() already accumulates multiple values for the same key into an
array internally, so calling it once per value is the correct approach.
Comma-joining violates RFC 6265 for Set-Cookie headers.
2026-04-01 15:43:14 +05:30
Chirag Aggarwal c9f7b7f0d9 fix: address PR review findings from code review
- Add Console::error() fallback in Bus::dispatch() so listener failures
  are visible even without telemetry (C1/M7)
- Remove duplicate $max/$sleep assignments in createDatabase (M1)
- Remove duplicate @param in Event::generateEvents docblock (M2)
- Remove unused $plan parameter from plan resource factory (M3)
- Fix inconsistent indentation in certificate init block (L2)
- Add explicit return null in session resource factory (M6)
2026-04-01 15:42:15 +05:30
Chirag Aggarwal cba7e53898 chore: fix composer 2026-04-01 15:37:34 +05:30
Chirag Aggarwal fb26da5df1 analyze fixes 2026-04-01 15:15:48 +05:30
Chirag Aggarwal eb8455bd76 revert 2026-04-01 14:29:20 +05:30
Chirag AggarwalandGitHub a76a03d988 Merge branch '1.9.x' into feat/migrate-di-container 2026-04-01 14:22:13 +05:30
Prem Palanisamy d9eb69aa47 Merge remote-tracking branch 'origin/1.9.x' into bump-database-version 2026-04-01 10:41:39 +02:00
Damodar LohaniandGitHub e250b413f0 Merge branch '1.9.x' into feat-audit-user-type-distinction 2026-04-01 12:58:09 +05:45
Harsh MahajanandGitHub 8ccfb1aebb Merge branch '1.9.x' into feat-x-oauth2-provider 2026-04-01 12:17:05 +05:30
Harsh Mahajan 9da4f19d4f fix: pkce flow 2026-04-01 12:11:40 +05:30
Chirag Aggarwal cc04c682b0 chore: use phpstan-baseline.neon from 1.9.x 2026-04-01 11:49:43 +05:30
Chirag Aggarwal 66ba483b6a chore: remove inapplicable phpstan baseline entries from 1.9.x merge
$register variable.undefined (app/http.php) and binary op (app/worker.php) suppressions don't apply to this branch's rewritten DI container code.
2026-04-01 11:48:41 +05:30
Chirag Aggarwal 908e408480 Merge remote-tracking branch 'origin/1.9.x' into feat/migrate-di-container
# Conflicts:
#	app/init/resources.php
#	composer.json
#	composer.lock
#	phpstan-baseline.neon
2026-04-01 11:46:13 +05:30
Prem Palanisamy 7f9ce1ca85 Merge remote-tracking branch 'origin/1.9.x' into bump-database-version 2026-04-01 07:37:57 +02:00
Prem Palanisamy 168166b9c1 bump utopia-php/database to 5.3.19 and fix shared-mode CI failures
- Bump utopia-php/database from 5.3.17 to 5.3.19

- Remove invalid (int) cast on tenant sequence in shared tables mode

- Fix DSN construction for documentsdb/vectorsdb: filter empty strings
  from explode(), skip pool filtering when shared tables env vars unset,
  fail fast when no pool found

- Use dedicated mode for separate database pools in getDatabasesDB
  since shared tables can't work across engines (PostgreSQL integer
  _tenant vs MongoDB UUID tenant). Auto-init schema on first use.

- Add documentsdb/vectorsdb shared tables env vars to CI workflow

- Fix testChannelTablesDBRowUpdate race condition with deterministic
  event drain loop
2026-03-31 21:24:22 +02:00
fogelito e42a26fdad comment 2026-03-31 19:20:13 +03:00
fogelito 2c4770d29b Database 5.3.19 2026-03-31 19:18:32 +03:00
fogelito 2a9e423cb1 Temporary disabling deletes from internal collections 2026-03-31 12:54:33 +03:00
fogelito 14869cc6d6 Safe delete shared tables v1 2026-03-31 09:59:53 +03:00
Harsh Mahajan fe99470374 revert test env change 2026-03-30 16:09:42 +05:30
Chirag Aggarwal ff903e7cbb lock file 2026-03-28 20:39:08 +05:30
Chirag AggarwalandGitHub 10ebf942b1 Merge branch '1.9.x' into feat/migrate-di-container 2026-03-28 20:36:08 +05:30
Matej Bačo d9a75c5938 Improved backwards compatibility 2026-03-27 15:34:34 +01:00
Matej Bačo bb80e50d01 Self review after refactor 2026-03-27 14:00:44 +01:00
Matej Bačo 644840ec66 Refactor to new platforms interfaces 2026-03-27 13:45:58 +01:00
Matej Bačo 71e714c66b Remove extra logging 2026-03-27 12:49:16 +01:00
Matej Bačo b2b187c57d review fixes 2026-03-27 12:35:48 +01:00
copilot-swe-agent[bot]andMeldiron ecc76c7520 fix: address review feedback - log abort exception, fix test cleanup
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/045e63f6-9a81-447b-ba79-7391ab97fb01

Co-authored-by: Meldiron <19310830+Meldiron@users.noreply.github.com>
2026-03-26 18:34:19 +00:00
copilot-swe-agent[bot]andMeldiron 898e8e214b fix: allow deletion of partially-uploaded (pending) files and add test
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/8d14b17a-78d8-48c6-b6d8-51d9697adde0

Co-authored-by: Meldiron <19310830+Meldiron@users.noreply.github.com>
2026-03-26 18:04:41 +00:00
Matej Bačo 43db2ddf6e Fix default null 2026-03-26 16:24:28 +01:00
Matej Bačo 7371b68418 Fix tests compatibility 2026-03-26 15:51:03 +01:00
Matej Bačo 7f4d5f692d Fix non-expiry key actions 2026-03-26 15:44:50 +01:00
Matej Bačo 8113854a89 AI review fixes 2026-03-26 15:37:57 +01:00
Matej Bačo eb097a037b Finish security todos 2026-03-26 15:35:40 +01:00
Matej Bačo cbfdd27834 Public keys Apis 2026-03-26 15:00:20 +01:00
Matej Bačo 113c266881 Fix audit resource ID missing 2026-03-26 14:43:38 +01:00
Harsh Mahajan 614db7388e fix: push 2026-03-26 17:59:30 +05:30
Harsh MahajanandGitHub cbc5259db3 Merge branch '1.9.x' into feat-x-oauth2-provider 2026-03-25 23:29:04 +05:30
Matej Bačo 0d6d6a0a35 Make tests pass 2026-03-25 16:11:01 +01:00
Matej Bačo e8d4065af5 Improve tests human review 2026-03-25 15:54:10 +01:00
Matej Bačo 008110b3f7 Platform API tests 2026-03-25 15:47:11 +01:00
Harsh MahajanandGitHub efb0cae4e0 Merge branch '1.9.x' into feat-x-oauth2-provider 2026-03-25 16:49:54 +05:30
Matej Bačo ae99d59aba CodeQL review 2026-03-24 14:52:58 +01:00
Matej Bačo a06aaaf9ca Remove Db schema changes 2026-03-24 13:55:54 +01:00
Chirag AggarwalandGitHub 4970d1b6f2 Merge branch '1.9.x' into feat/migrate-di-container 2026-03-24 18:15:45 +05:30
Matej Bačo 094cd180b5 Remove leftover 2026-03-24 13:41:12 +01:00
Matej Bačo a8f43f3486 Update DB schema 2026-03-24 13:36:11 +01:00
Matej Bačo 038f4b5992 More backwards compatibility fixes 2026-03-24 13:33:15 +01:00
Matej Bačo c903fb87ac Fix backwards compatibility 2026-03-24 12:53:25 +01:00
Matej Bačo e5841c4cc0 Fix syntax error 2026-03-24 11:29:24 +01:00
Matej Bačo bb7815bd57 Merge branch '1.9.x' into feat-public-platform-api 2026-03-24 11:18:18 +01:00
Chirag Aggarwal 0c33d981a7 fix analyze 2026-03-24 10:47:41 +05:30
Chirag Aggarwal 36d8ad6e7c lock file 2026-03-24 10:21:40 +05:30
Chirag Aggarwal fbce66d500 fix merge conflict 2026-03-24 10:19:34 +05:30
Chirag Aggarwal 89db65299d Merge remote-tracking branch 'origin/1.9.x' into feat/migrate-di-container 2026-03-24 10:15:38 +05:30
Chirag Aggarwal ea6a05be4f fix analyze 2026-03-24 07:51:23 +05:30
Chirag Aggarwal 355acf6824 Merge branch '1.9.x' into feat/migrate-di-container 2026-03-23 23:01:30 +05:30
Chirag Aggarwal c2988efa08 chore: use stable 2026-03-23 22:51:49 +05:30
Matej Bačo 8ccf5094f8 fix multi-model conditions 2026-03-23 16:42:14 +01:00
Matej Bačo 39f2d24907 AI code review fixes 2026-03-23 16:10:12 +01:00
Matej Bačo d3c5a425e7 Implement public platform API 2026-03-23 15:46:11 +01:00
Harsh Mahajan 85703d29e1 addressed greptile suggestions 2026-03-23 19:08:12 +05:30
Harsh MahajanandGitHub d3c11b5c93 Merge branch '1.9.x' into feat-x-oauth2-provider 2026-03-23 18:51:10 +05:30
Harsh MahajanandGitHub 6f177a0a7a Merge branch '1.9.x' into feat-x-oauth2-provider 2026-03-23 17:50:29 +05:30
Harsh Mahajan dc48bb35ef added pkce to base 2026-03-23 17:49:42 +05:30
Harsh Mahajan 8218f36d34 code rabbit comment 2026-03-23 13:32:06 +05:30
Harsh Mahajan 0fe906c538 feat: Add X OAuth 2.0 provider 2026-03-23 13:21:04 +05:30
Chirag Aggarwal 4641596a6d use stable 2026-03-23 10:28:26 +05:30
Chirag Aggarwal d932527561 add null collacing 2026-03-23 10:27:10 +05:30
Chirag Aggarwal 89c072e223 fix analyze 2026-03-23 10:20:45 +05:30
Chirag Aggarwal 6421bc8689 fn name 2026-03-23 10:08:19 +05:30
Chirag Aggarwal d008d9bff0 merge conficts 2026-03-23 10:01:27 +05:30
Chirag Aggarwal f6dc6359bb Merge branch '1.9.x' into feat/migrate-di-container 2026-03-23 10:01:03 +05:30
Damodar LohaniandGitHub 31410879a7 Merge branch '1.9.x' into feat-audit-user-type-distinction 2026-03-22 08:36:13 +05:45
Damodar Lohani 343e352b17 fix: prevent overwriting user type in audit queue if already set 2026-03-22 02:31:46 +00:00
Damodar LohaniandGitHub b38ea72407 Merge branch '1.8.x' into feat-audit-user-type-distinction 2026-03-22 08:05:29 +05:45
Chirag Aggarwal 032638e896 fix 2026-03-20 15:35:28 +05:30
Chirag Aggarwal 10cc6a8040 fix global pools state 2026-03-20 14:09:43 +05:30
Chirag Aggarwal 9ecdbf5950 func exists 2026-03-20 13:13:07 +05:30
Chirag Aggarwal 55b436c67b lock file 2026-03-20 12:17:28 +05:30
Chirag Aggarwal 05defcc6e0 Merge branch '1.9.x' into feat/migrate-di-container 2026-03-20 12:12:29 +05:30
Chirag Aggarwal 6700340ef3 fix realtime 2026-03-19 23:26:49 +05:30
Chirag Aggarwal 4224f6ea5a merge conficts 2026-03-19 22:15:31 +05:30
Chirag Aggarwal 8d925f3670 merge conficts 2026-03-19 21:35:19 +05:30
Chirag Aggarwal d2875c9bf6 Merge branch '1.8.x' into feat/migrate-di-container 2026-03-19 21:35:06 +05:30
Chirag Aggarwal c002f4afe3 fix specs generation 2026-03-19 21:23:00 +05:30
Chirag Aggarwal ecb8104340 register resources 2026-03-19 17:37:47 +05:30
Chirag Aggarwal e6090800e2 register resources 2026-03-19 17:35:31 +05:30
Damodar LohaniandGitHub 92b7760e94 Merge branch '1.8.x' into feat-audit-user-type-distinction 2026-03-19 17:44:50 +05:45
Damodar LohaniGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
fe988f4489 Update app/controllers/shared/api.php
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 17:44:35 +05:45
Chirag AggarwalandGitHub 0237cced6b Merge branch '1.8.x' into feat/migrate-di-container 2026-03-19 14:10:20 +05:30
Chirag Aggarwal 625ec4ce91 sync changes 2026-03-19 14:09:04 +05:30
Chirag Aggarwal 5339592b3f update http 2026-03-19 10:20:47 +05:30
Chirag Aggarwal 9f1d092717 analyze fixes 2026-03-19 10:17:25 +05:30
Chirag Aggarwal e38a4e4347 update queue 2026-03-19 10:08:40 +05:30
Chirag Aggarwal fdbc5b6737 update queue 2026-03-19 10:07:08 +05:30
Chirag Aggarwal fa2b7955d0 update dependencies 2026-03-19 10:04:47 +05:30
Damodar LohaniandClaude Opus 4.6 8b3d3c6f8b feat: distinguish user types in audit logs
Introduce granular audit user types to differentiate between regular
users, console admins, guests, and the various API key scopes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 03:55:22 +00:00
Chirag Aggarwal ea11af1e15 chore: worker changes 2026-03-18 17:50:25 +05:30
Chirag AggarwalandGitHub 6cbae88131 Merge branch '1.8.x' into feat/migrate-di-container 2026-03-18 17:14:45 +05:30
Chirag Aggarwal 03b2755aaf fix async execution 2026-03-18 16:43:14 +05:30
Chirag AggarwalandGitHub 2095a94bc5 Merge branch '1.8.x' into feat/migrate-di-container 2026-03-18 14:19:59 +05:30
Chirag Aggarwal df7796d5cb restore phpunit 2026-03-18 14:19:43 +05:30
Chirag Aggarwal d177e3adfc fix pool issue 2026-03-18 11:27:26 +05:30
Chirag Aggarwal d04ac5c3e6 fix pool issue 2026-03-18 09:42:18 +05:30
Chirag Aggarwal 60939da801 fix graphql 2026-03-17 21:39:50 +05:30
Chirag Aggarwal cdb301a293 fix PHPStan errors without regenerating baseline
- Fix dispatch() type hint to use \Swoole\Http\Server instead of Utopia adapter
- Remove unused $register from go() closure in http.php
- Remove unnecessary ?? '' on non-nullable $hostname
- Remove unsupported override: param from addHeader() call
- Update Resolvers.php for new getResource()/execute() signatures
- Migrate Installer/Server.php from static Http::setResource() to container
- Remove stale baseline entries, add 1 for pre-existing Deployment.php issue
2026-03-17 17:30:42 +05:30
Chirag Aggarwal 14a9aa890f fix issues 2026-03-17 17:18:57 +05:30
Chirag Aggarwal d3833d06d5 Merge 1.8.x (accept theirs) 2026-03-17 17:15:54 +05:30
Chirag Aggarwal 5ba19ec763 fix alias 2026-03-17 17:14:35 +05:30
Chirag Aggarwal 0564936c4f update file name 2026-03-17 16:29:21 +05:30
Chirag Aggarwal c2795376d8 update server 2026-03-17 16:25:52 +05:30
Chirag AggarwalandGitHub 2b898dfe50 Merge branch '1.8.x' into feat/migrate-di-container 2026-03-17 15:22:02 +05:30
Chirag Aggarwal fa1404be52 cleanup 2026-03-17 15:20:29 +05:30
Chirag Aggarwal be87675e1c fix realtime 2026-03-17 15:03:55 +05:30
Chirag Aggarwal b1edf75c4e phpunit 2026-03-17 14:29:04 +05:30
Chirag Aggarwal 8a0f092342 fix: only throw container lookup errors 2026-03-17 14:28:37 +05:30
Chirag Aggarwal 87b02de612 baseline 2026-03-17 11:32:15 +05:30
Chirag Aggarwal f8e9f71de3 move resources 2026-03-17 11:19:17 +05:30
Chirag Aggarwal c055c32371 enable coroutines 2026-03-17 10:52:35 +05:30
Chirag Aggarwal d60858cd8f fix phpstan 2026-03-17 10:37:49 +05:30
Chirag Aggarwal 22bd655ad1 fix autoload 2026-03-17 10:28:08 +05:30
Chirag Aggarwal c900b22dc0 fix connection container and view class 2026-03-17 09:52:55 +05:30
Chirag Aggarwal 27e5ade92b fix login 2026-03-17 09:43:54 +05:30
Chirag Aggarwal d9c1b9db2a chore: register request resources seperately 2026-03-17 08:49:43 +05:30
Chirag Aggarwal eb399c7bd0 wip 2026-03-16 23:45:18 +05:30
Chirag Aggarwal b8e51366d7 add missing compression methods 2026-03-16 23:32:31 +05:30
Chirag Aggarwal b75cd993be missing trusted ip headers 2026-03-16 23:27:34 +05:30
Chirag Aggarwal a1bc503ce4 formatting 2026-03-16 23:23:03 +05:30
Chirag Aggarwal e475a7ac5a lock file 2026-03-16 23:19:07 +05:30
Chirag Aggarwal 2dc24dabfd Merge branch '1.8.x' into feat/migrate-di-container 2026-03-16 23:10:09 +05:30
Chirag Aggarwal aaa2a0525f feat: migrate from static Http::setResource() to DI Container
Upgrade utopia-php/framework from 0.33.x to 0.34.x which removes the
static Http::setResource() API. Resources are now registered on a
Utopia\DI\Container instance.

- Replace 81 Http::setResource() calls in resources.php with $container->set()
- Refactor http.php to use Swoole HttpServer adapter with shared container
- Refactor realtime.php to use FPM adapter with global container
- Refactor cli.php to use direct $cli->setResource() calls
- Update Specs.php to use local container + FPM adapter
- Update Migrate.php to inject console document instead of creating Http instance
- Update GraphQL Schema.php to use instance setResource()
2026-03-16 23:00:36 +05:30
526 changed files with 30401 additions and 9989 deletions
@@ -0,0 +1,29 @@
# Patch Release Checklist for Appwrite
When bumping a patch version (e.g., `1.9.0` -> `1.9.1`), follow this checklist.
## Checklist
### Bump console image
Update the console Docker image tag in both files:
- [ ] `docker-compose.yml` -- update `image: appwrite/console:X.Y.Z`
- [ ] `app/views/install/compose.phtml` -- update `image: <?php echo $organization; ?>/console:X.Y.Z`
### Bump Appwrite version
- [ ] **`app/init/constants.php`** -- update `APP_VERSION_STABLE` to the new version (e.g., `'1.9.1'`). In same file, increment `APP_CACHE_BUSTER` by 1.
- [ ] **`README.md`** -- update the Docker image tag `appwrite/appwrite:X.Y.Z` in all 3 install code blocks (Unix, Windows CMD, PowerShell).
- [ ] **`README-CN.md`** -- same Docker image tag update in all 3 install code blocks.
- [ ] **`src/Appwrite/Migration/Migration.php`** -- add the new version to the `$versions` array, mapping it to a migration class. If new class exists, use that, otherwise use sle same class as previous version
### Update CHANGES.md
- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md` with subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous`
## Final review
- [ ] Ask user to review changes before commiting
- [ ] Ask user to update `CHANGES.md` with PRs
- [ ] Ask user to generate specs, if needed
- [ ] Ask user to add request and response filters, if needed
+349
View File
@@ -0,0 +1,349 @@
const fs = require('fs');
const marker = '<!-- appwrite-benchmark-results -->';
const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions'];
module.exports = async ({ github, context, core }) => {
const body = buildComment(core);
fs.writeFileSync('benchmark-comment.txt', body);
const pullRequest = context.payload.pull_request;
if (!pullRequest || pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
return;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const existing = comments.find((comment) => {
return comment.user?.type === 'Bot' && comment.body?.includes(marker);
}) || comments.find((comment) => {
return comment.user?.type === 'Bot' && comment.body?.includes('Benchmark results');
});
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body,
});
};
function buildComment(core) {
const before = readSummary('benchmark-before-summary.json', core);
const after = readSummary('benchmark-after-summary.json', core);
const beforeSamples = readSamples('benchmark-before-samples.json', core);
const afterSamples = readSamples('benchmark-after-samples.json', core);
const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base');
const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head');
const rows = benchmarkRows(before, after, beforeSamples, afterSamples);
const topWaits = topSamples(afterSamples, 'appwrite_api_waiting', 3);
const lines = [
marker,
'## :sparkles: Benchmark results',
'',
`Comparing ${baseRef} (before) to ${headRef} (after).`,
'',
];
if (before === null) {
lines.push('> Before benchmark did not complete; showing current branch metrics only.', '');
}
if (after === null) {
lines.push('> Current branch benchmark did not complete; showing available metrics only.', '');
}
lines.push(
'**Before**',
'',
metricTable(rows, 'before'),
'',
'**After**',
'',
metricTable(rows, 'after'),
'',
'**Delta**',
'',
'| Scenario | P95 delta (ms) |',
'| --- | ---: |',
...rows.map(deltaRow),
'',
'<details>',
'<summary><strong>Top API waits</strong></summary>',
'',
'<br>',
'',
'| API request | Max wait (ms) |',
'| --- | ---: |',
...topWaitRows(topWaits),
'',
'</details>',
);
return `${lines.join('\n')}\n`;
}
function readSummary(path, core) {
if (!fs.existsSync(path)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(path, 'utf8'));
} catch (error) {
core?.warning(`Invalid benchmark summary ${path}: ${error.message}`);
return null;
}
}
function readSamples(path, core) {
if (!fs.existsSync(path)) {
return [];
}
const contents = fs.readFileSync(path, 'utf8').trim();
if (contents === '') {
return [];
}
return contents
.split('\n')
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line)];
} catch (error) {
core?.warning(`Invalid benchmark sample in ${path}: ${error.message}`);
return [];
}
});
}
function benchmarkRows(before, after, beforeSamples, afterSamples) {
const beforeServices = serviceStats(beforeSamples);
const afterServices = serviceStats(afterSamples);
return [
{
label: 'API total',
before: apiSampleStats(beforeSamples) || summaryStats(before, 'appwrite_api_duration'),
after: apiSampleStats(afterSamples) || summaryStats(after, 'appwrite_api_duration'),
},
...serviceLabels.map((label) => ({
label,
before: beforeServices.get(label) || null,
after: afterServices.get(label) || null,
})),
];
}
function summaryStats(summary, durationMetric, iterationsMetric = null, rpsMetric = null) {
const values = metricValues(summary, durationMetric);
if (!values) {
return null;
}
return {
p50: values.med ?? null,
p95: values['p(95)'] ?? null,
iterations: iterationsMetric ? metricValue(summary, iterationsMetric, 'count') : values.count ?? null,
rps: rpsMetric ? metricValue(summary, rpsMetric, 'rate') : null,
};
}
function serviceStats(samples) {
const apiSamples = samples.filter((sample) => {
return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
});
const groups = new Map();
for (const sample of apiSamples) {
const service = serviceFromName(sample.data.tags?.name || '');
if (!service) {
continue;
}
const serviceSamples = groups.get(service) || [];
serviceSamples.push(sample);
groups.set(service, serviceSamples);
}
return new Map([...groups.entries()].map(([service, serviceSamples]) => {
const values = serviceSamples.map((sample) => sample.data.value);
const durationSeconds = sampleWindowSeconds(serviceSamples);
return [service, {
p50: percentile(values, 50),
p95: percentile(values, 95),
iterations: values.length,
rps: durationSeconds ? values.length / durationSeconds : null,
}];
}));
}
function apiSampleStats(samples) {
const apiSamples = samples.filter((sample) => {
return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
});
const values = apiSamples.map((sample) => sample.data.value);
if (values.length === 0) {
return null;
}
const durationSeconds = sampleWindowSeconds(apiSamples);
return {
p50: percentile(values, 50),
p95: percentile(values, 95),
iterations: values.length,
rps: durationSeconds ? values.length / durationSeconds : null,
};
}
function serviceFromName(name) {
if (name.startsWith('account.')) {
return 'Account';
}
if (name.startsWith('tablesdb.')) {
return 'TablesDB';
}
if (name.startsWith('storage.') || name.startsWith('tokens.')) {
return 'Storage';
}
if (name.startsWith('functions.')) {
return 'Functions';
}
return null;
}
function sampleWindowSeconds(samples) {
const times = samples
.map((sample) => Date.parse(sample.data?.time))
.filter((value) => !Number.isNaN(value));
if (times.length < 2) {
return null;
}
return Math.max((Math.max(...times) - Math.min(...times)) / 1000, 1);
}
function percentile(values, percentileValue) {
if (values.length === 0) {
return null;
}
const sorted = [...values].sort((left, right) => left - right);
const index = Math.ceil((percentileValue / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
}
function metricValues(data, metric) {
return data?.metrics?.[metric]?.values ?? null;
}
function metricValue(data, metric, stat) {
return metricValues(data, metric)?.[stat] ?? null;
}
function metricTable(rows, side) {
return [
'| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |',
'| --- | ---: | ---: | ---: | ---: |',
...rows.map((row) => metricRow(row, side)),
].join('\n');
}
function metricRow(row, side) {
const values = row[side];
return `| ${row.label} | ${formatMs(values?.p50)} | ${formatMs(values?.p95)} | ${formatCount(values?.iterations)} | ${formatRate(values?.rps)} |`;
}
function deltaRow(row) {
return `| ${row.label} | ${formatDelta(row.before?.p95, row.after?.p95)} |`;
}
function topSamples(samples, metric, limit) {
const byName = samples.reduce((result, sample) => {
if (sample.metric !== metric || typeof sample.data?.value !== 'number') {
return result;
}
const name = sample.data.tags?.name || 'unknown';
const current = result.get(name);
if (!current || sample.data.value > current.value) {
result.set(name, { name, value: sample.data.value });
}
return result;
}, new Map());
return [...byName.values()]
.sort((left, right) => right.value - left.value)
.slice(0, limit);
}
function topWaitRows(samples) {
if (samples.length === 0) {
return ['| n/a | n/a |'];
}
return samples.map((sample) => {
return `| ${markdownText(sample.name).replace(/\|/g, '\\|')} | ${formatMs(sample.value)} |`;
});
}
function markdownText(value) {
return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' })[char];
});
}
function formatMs(value) {
return formatNumber(value, 2);
}
function formatRate(value) {
return formatNumber(value, 2);
}
function formatCount(value) {
if (value === null || value === undefined || Number.isNaN(value)) {
return 'n/a';
}
return `${Math.round(value)}`;
}
function formatDelta(before, after) {
if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) {
return 'n/a';
}
const difference = Number((after - before).toFixed(2));
return `${difference > 0 ? '+' : ''}${trimNumber(difference)}`;
}
function formatNumber(value, decimals) {
if (value === null || value === undefined || Number.isNaN(value)) {
return 'n/a';
}
return trimNumber(Number(value).toFixed(decimals));
}
function trimNumber(value) {
const text = String(value);
const trimmed = text.includes('.') ? text.replace(/\.?0+$/, '') : text;
return trimmed === '' ? '0' : trimmed;
}
+177 -67
View File
@@ -7,6 +7,7 @@ concurrency:
env:
COMPOSE_FILE: docker-compose.yml
IMAGE: appwrite-dev
K6_VERSION: '0.53.0'
on:
pull_request:
@@ -161,6 +162,27 @@ jobs:
- name: Run PHPStan
run: composer analyze -- --no-progress
specs:
name: Checks / Specs
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: swoole
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
- name: Generate specs
run: _APP_STORAGE_LIMIT=5368709120 php app/cli.php specs --version=latest --git=no
locale:
name: Checks / Locale
runs-on: ubuntu-latest
@@ -189,7 +211,7 @@ jobs:
with:
script: |
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
const allModes = ['dedicated', 'shared_v1', 'shared_v2'];
const allModes = ['dedicated', 'shared'];
const defaultDatabases = ['MongoDB'];
const defaultModes = ['dedicated'];
@@ -408,6 +430,8 @@ jobs:
include:
- service: Databases
runner: blacksmith-4vcpu-ubuntu-2404
paratest_processes: 3
timeout_minutes: 30
- service: Sites
runner: blacksmith-4vcpu-ubuntu-2404
- service: Functions
@@ -418,6 +442,10 @@ jobs:
runner: blacksmith-4vcpu-ubuntu-2404
- service: TablesDB
runner: blacksmith-4vcpu-ubuntu-2404
paratest_processes: 3
timeout_minutes: 30
- service: Migrations
paratest_processes: 1
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -458,7 +486,8 @@ jobs:
env:
_APP_BROWSER_HOST: http://invalid-browser/v1
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
@@ -477,7 +506,7 @@ jobs:
with:
max_attempts: 2
retry_wait_seconds: 60
timeout_minutes: 20
timeout_minutes: ${{ matrix.timeout_minutes || 20 }}
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/${{ matrix.service }}
@@ -487,12 +516,17 @@ jobs:
# Services that rely on sequential test method execution (shared static state)
FUNCTIONAL_FLAG="--functional"
case "${{ matrix.service }}" in
Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;;
esac
PARATEST_PROCESSES="${{ matrix.paratest_processes }}"
if [ -z "$PARATEST_PROCESSES" ]; then
PARATEST_PROCESSES="$(nproc)"
fi
docker compose exec -T \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
- name: Failure Logs
if: failure()
@@ -514,6 +548,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Download Docker Image
uses: actions/download-artifact@v7
@@ -532,7 +568,8 @@ jobs:
env:
_APP_OPTIONS_ABUSE: enabled
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
@@ -589,7 +626,8 @@ jobs:
timeout-minutes: 5
env:
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
@@ -625,13 +663,19 @@ jobs:
benchmark:
name: Benchmark
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: build
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Download Docker Image
uses: actions/download-artifact@v7
@@ -645,79 +689,145 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load and Start Appwrite
- name: Load Appwrite image
run: |
sed -i 's/traefik/localhost/g' .env
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose up -d
sleep 10
docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after
- name: Install Oha
- name: Setup k6
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
with:
k6-version: ${{ env.K6_VERSION }}
- name: Prepare benchmark before
id: benchmark_before_prepare
continue-on-error: true
run: |
echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
sudo apt update
sudo apt install oha
oha --version
git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
git worktree add --detach /tmp/appwrite-benchmark-before ${{ github.event.pull_request.base.sha }}
docker build \
--cache-from ${{ env.IMAGE }}:after \
--target development \
--build-arg DEBUG=false \
--build-arg TESTING=true \
--build-arg VERSION=dev \
--tag ${{ env.IMAGE }}:before \
/tmp/appwrite-benchmark-before
- name: Benchmark PR
run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
- name: Cleaning
run: docker compose down -v
- name: Installing latest version
- name: Start before Appwrite
id: benchmark_before_start
if: steps.benchmark_before_prepare.outcome == 'success'
continue-on-error: true
working-directory: /tmp/appwrite-benchmark-before
env:
_APP_DOMAIN: localhost
_APP_CONSOLE_DOMAIN: localhost
_APP_DOMAIN_FUNCTIONS: functions.localhost
_APP_OPTIONS_ABUSE: disabled
run: |
rm docker-compose.yml
rm .env
curl https://appwrite.io/install/compose -o docker-compose.yml
curl https://appwrite.io/install/env -o .env
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
docker compose up -d
sleep 10
docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }}
docker compose up -d --wait --no-build
- name: Benchmark Latest
run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
- name: Prepare benchmark files
run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json
- name: Prepare comment
- name: Benchmark before
if: steps.benchmark_before_start.outcome == 'success'
continue-on-error: true
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
env:
APPWRITE_ENDPOINT: 'http://localhost/v1'
APPWRITE_BENCHMARK_ITERATIONS: '5'
APPWRITE_BENCHMARK_VUS: '1'
APPWRITE_WORKER_TIMEOUT_MS: '120000'
APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-before-summary.json'
with:
path: tests/benchmarks/http.js
flags: --quiet --out json=benchmark-before-samples.json
cloud-comment-on-pr: false
debug: true
- name: Stop before Appwrite
if: always()
run: |
echo '## :sparkles: Benchmark results' > benchmark.txt
echo ' ' >> benchmark.txt
echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
echo " " >> benchmark.txt
echo " " >> benchmark.txt
echo "## :zap: Benchmark Comparison" >> benchmark.txt
echo " " >> benchmark.txt
echo "| Metric | This PR | Latest version | " >> benchmark.txt
echo "| --- | --- | --- | " >> benchmark.txt
echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
if [ -d /tmp/appwrite-benchmark-before ]; then
cd /tmp/appwrite-benchmark-before
docker compose down -v || true
fi
- name: Wait for benchmark ports
if: always()
run: |
for port in 80 443 8080 9503; do
for attempt in $(seq 1 30); do
if ! ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
break
fi
sleep 1
done
if ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
echo "Port ${port} is still in use after stopping the before stack"
ss -ltn
exit 1
fi
done
- name: Start after Appwrite
env:
_APP_DOMAIN: localhost
_APP_CONSOLE_DOMAIN: localhost
_APP_DOMAIN_FUNCTIONS: functions.localhost
_APP_OPTIONS_ABUSE: disabled
run: |
docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }}
docker compose up -d --wait --no-build
- name: Benchmark after
id: benchmark_after
continue-on-error: true
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
env:
APPWRITE_ENDPOINT: 'http://localhost/v1'
APPWRITE_BENCHMARK_ITERATIONS: '5'
APPWRITE_BENCHMARK_VUS: '1'
APPWRITE_WORKER_TIMEOUT_MS: '120000'
APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json'
APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json'
with:
path: tests/benchmarks/http.js
flags: --quiet --out json=benchmark-after-samples.json
cloud-comment-on-pr: false
debug: true
- name: Stop after Appwrite
if: always()
run: docker compose down -v || true
- name: Comment on PR
if: always()
uses: actions/github-script@v8
env:
BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
with:
script: |
const comment = require('./.github/workflows/benchmark-comment.js');
await comment({ github, context, core });
- name: Save results
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: benchmark.json
path: benchmark.json
name: benchmark-results
path: |
benchmark-comment.txt
benchmark-before-summary.json
benchmark-after-summary.json
benchmark-before-samples.json
benchmark-after-samples.json
retention-days: 7
- name: Find Comment
if: github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: Benchmark results
- name: Comment on PR
if: github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body-path: benchmark.txt
edit-mode: replace
- name: Fail benchmark
if: always() && steps.benchmark_after.outcome != 'success'
run: exit 1
+4
View File
@@ -115,6 +115,10 @@ Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, `
- Never hardcode credentials -- use environment variables.
- Code changes may require container restart. No central log location -- check relevant containers.
## Patch release process
For bumping patch versions (e.g., `1.9.0` -> `1.9.1`), follow the checklist in `.claude/skills/patch-release-checklist/SKILL.md`. It covers the 4 files that must be updated, console image bumps, CHANGES.md updates, and common pitfalls to avoid.
## Cross-repo context
Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console.
+1 -1
View File
@@ -892,7 +892,7 @@
* Unset index length by @fogelito in https://github.com/appwrite/appwrite/pull/8978
* Update base to 0.9.5 by @basert in https://github.com/appwrite/appwrite/pull/9005
* Sync main into 1.6.x by @TorstenDittmann in https://github.com/appwrite/appwrite/pull/9011
* Improved shared tables V2 by @abnegate in https://github.com/appwrite/appwrite/pull/9013
* Improved shared tables by @abnegate in https://github.com/appwrite/appwrite/pull/9013
* Ensure backwards compatibility for 1.6.x by @christyjacob4 in https://github.com/appwrite/appwrite/pull/9018
# Version 1.6.0
+1 -1
View File
@@ -44,7 +44,7 @@ Table of Contents:
## Products
- **[Appwrite Auth](https://appwrite.io/docs/products/authentication)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows.
- **[Appwrite Auth](https://appwrite.io/docs/products/auth)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows.
- **[Appwrite Databases](https://appwrite.io/docs/products/databases)** - Scalable structured data storage with support for databases, tables, and rows. Includes querying, pagination, indexing, and relationships to model complex application data.
+42 -44
View File
@@ -2,12 +2,12 @@
require_once __DIR__ . '/init.php';
use Appwrite\Event\Certificate;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Publisher\Certificate as CertificatePublisher;
use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\StatsResources;
use Appwrite\Platform\Appwrite;
use Appwrite\Runtimes\Runtimes;
use Appwrite\Usage\Context as UsageContext;
@@ -18,13 +18,15 @@ use Swoole\Timer;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
use Utopia\CLI\Adapters\Generic;
use Utopia\CLI\CLI;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Dependency;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Platform\Service;
@@ -47,7 +49,7 @@ require_once __DIR__ . '/controllers/general.php';
global $register;
$platform = new Appwrite();
$args = $platform->getEnv('argv');
$args = $_SERVER['argv'] ?? [];
\array_shift($args);
if (! isset($args[0])) {
@@ -56,21 +58,15 @@ if (! isset($args[0])) {
}
$taskName = $args[0];
$container = new Container();
$cli = new CLI(new Generic(), $_SERVER['argv'] ?? [], $container);
$platform->setCli($cli);
$platform->init(Service::TYPE_TASK);
$cli = $platform->getCli();
$setResource = function (string $name, callable $callback, array $injections = []) use ($cli) {
$dependency = new Dependency();
$dependency->setName($name)->setCallback($callback);
foreach ($injections as $injection) {
$dependency->inject($injection);
}
$cli->setResource($dependency);
};
$container->set('register', fn () => $register, []);
$setResource('register', fn () => $register, []);
$setResource('cache', function ($pools) {
$container->set('cache', function ($pools) {
$list = Config::getParam('pools-cache', []);
$adapters = [];
@@ -81,18 +77,18 @@ $setResource('cache', function ($pools) {
return new Cache(new Sharding($adapters));
}, ['pools']);
$setResource('pools', function (Registry $register) {
$container->set('pools', function (Registry $register) {
return $register->get('pools');
}, ['register']);
$setResource('authorization', function () {
$container->set('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
return $authorization;
}, []);
$setResource('dbForPlatform', function ($pools, $cache, $authorization) {
$container->set('dbForPlatform', function ($pools, $cache, $authorization) {
$sleep = 3;
$maxAttempts = 5;
$attempts = 0;
@@ -135,17 +131,17 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) {
return $dbForPlatform;
}, ['pools', 'cache', 'authorization']);
$setResource('console', function () {
$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
$setResource(
$container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false,
[]
);
$setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) {
$container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) {
$databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) {
@@ -207,10 +203,10 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
$setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
return function (?Document $project = null) use ($pools, $cache, &$database, $authorization) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant($project->getSequence());
return $database;
@@ -235,41 +231,43 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
return $database;
};
}, ['pools', 'cache', 'authorization']);
$setResource('publisher', function (Group $pools) {
$container->set('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
$setResource('publisherDatabases', function (BrokerPool $publisher) {
$container->set('publisherDatabases', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$setResource('publisherFunctions', function (BrokerPool $publisher) {
$container->set('publisherFunctions', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$setResource('publisherMigrations', function (BrokerPool $publisher) {
$container->set('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$setResource('publisherMessaging', function (BrokerPool $publisher) {
$container->set('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$setResource('usage', function () {
$container->set('usage', function () {
return new UsageContext();
}, []);
$setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
$setResource('queueForStatsResources', function (Publisher $publisher) {
return new StatsResources($publisher);
}, ['publisher']);
$setResource('queueForFunctions', function (Publisher $publisher) {
$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher(
$publisher,
new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
), ['publisher']);
$container->set('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
$setResource('queueForDeletes', function (Publisher $publisher) {
$container->set('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
$setResource('queueForCertificates', function (Publisher $publisher) {
return new Certificate($publisher);
}, ['publisher']);
$setResource('logError', function (Registry $register) {
$container->set('logError', function (Registry $register) {
return function (Throwable $error, string $namespace, string $action) use ($register) {
Console::error('[Error] Timestamp: ' . date('c', time()));
Console::error('[Error] Type: ' . get_class($error));
@@ -321,13 +319,13 @@ $setResource('logError', function (Registry $register) {
};
}, ['register']);
$setResource('executor', fn () => new Executor(), []);
$container->set('executor', fn () => new Executor(), []);
$setResource('bus', function (Registry $register) use ($cli) {
return $register->get('bus')->setResolver(fn (string $name) => $cli->getResource($name));
$container->set('bus', function (Registry $register) use ($container) {
return $register->get('bus')->setResolver(fn (string $name) => $container->get($name));
}, ['register']);
$setResource('telemetry', fn () => new NoTelemetry(), []);
$container->set('telemetry', fn () => new NoTelemetry(), []);
$exitCode = 0;
+3 -3
View File
@@ -594,7 +594,7 @@ $platformCollections = [
'filters' => [],
],
[
'$id' => ID::custom('key'),
'$id' => ID::custom('key'), // For app platforms
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
@@ -605,7 +605,7 @@ $platformCollections = [
'filters' => [],
],
[
'$id' => ID::custom('store'),
'$id' => ID::custom('store'), // Unused at the moment
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
@@ -616,7 +616,7 @@ $platformCollections = [
'filters' => [],
],
[
'$id' => ID::custom('hostname'),
'$id' => ID::custom('hostname'), // For web platforms
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
+5
View File
@@ -34,6 +34,11 @@ $console = [
'legalAddress' => '',
'legalTaxId' => '',
'auths' => [
'membershipsUserName' => true,
'membershipsUserEmail' => true,
'membershipsMfa' => true,
'membershipsUserId' => true,
'membershipsUserPhone' => true,
'mockNumbers' => [],
'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
+25
View File
@@ -1179,6 +1179,16 @@ return [
'description' => 'Platform with the requested ID could not be found.',
'code' => 404,
],
Exception::PLATFORM_METHOD_UNSUPPORTED => [
'name' => Exception::PLATFORM_METHOD_UNSUPPORTED,
'description' => 'The requested platform has invalid type. Please use corresponding update method for the platform type.',
'code' => 400,
],
Exception::PLATFORM_ALREADY_EXISTS => [
'name' => Exception::PLATFORM_ALREADY_EXISTS,
'description' => 'Platform with the same ID already exists in this project. Try again with a different ID.',
'code' => 409,
],
Exception::VARIABLE_NOT_FOUND => [
'name' => Exception::VARIABLE_NOT_FOUND,
'description' => 'Variable with the requested ID could not be found.',
@@ -1398,4 +1408,19 @@ return [
'description' => 'When using project API key, make sure to pass x-appwrite-project header with your project ID.',
'code' => 403,
],
Exception::MOCK_NUMBER_ALREADY_EXISTS => [
'name' => Exception::MOCK_NUMBER_ALREADY_EXISTS,
'description' => 'Mock number with the requested number already exists. Try again with a different number. or update OTP of existing mock number.',
'code' => 409,
],
Exception::MOCK_NUMBER_NOT_FOUND => [
'name' => Exception::MOCK_NUMBER_NOT_FOUND,
'description' => 'Mock number with the requested number could not be found.',
'code' => 404,
],
Exception::MOCK_NUMBER_LIMIT_EXCEEDED => [
'name' => Exception::MOCK_NUMBER_LIMIT_EXCEEDED,
'description' => 'The maximum number of mock phones for this project has been reached.',
'code' => 400,
],
];
-6
View File
@@ -9,11 +9,5 @@ return [
'mfaChallenge',
'sessionAlert',
'otpSession'
],
'sms' => [
'verification',
'login',
'invitation',
'mfaChallenge'
]
];
+11
View File
@@ -376,6 +376,17 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Wordpress',
],
'x' => [
'name' => 'X',
'developers' => 'https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code',
'icon' => 'icon-twitter',
'enabled' => true,
'sandbox' => false,
'form' => false,
'beta' => false,
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\X',
],
'yahoo' => [
'name' => 'Yahoo',
'developers' => 'https://developer.yahoo.com/oauth2/guide/flows_authcode/',
@@ -9,8 +9,8 @@ return [
'key' => 'graphql',
'name' => 'GraphQL',
],
'realtime' => [
'key' => 'realtime',
'name' => 'Realtime',
'websocket' => [
'key' => 'websocket',
'name' => 'Websocket',
],
];
+6
View File
@@ -55,6 +55,12 @@ $admins = [
'tables.write',
'platforms.read',
'platforms.write',
'mocks.read',
'mocks.write',
'policies.read',
'policies.write',
'templates.read',
'templates.write',
'projects.write',
'keys.read',
'keys.write',
-14
View File
@@ -3,13 +3,6 @@
// List of scopes for organization (teams) API keys
return [
"platforms.read" => [
"description" => 'Access to read project\'s platforms',
],
"platforms.write" => [
"description" =>
'Access to create, update, and delete project\'s platforms',
],
"projects.read" => [
"description" => 'Access to read organization\'s projects',
],
@@ -17,13 +10,6 @@ return [
"description" =>
"Access to create, update, and delete projects in organization",
],
"keys.read" => [
"description" => 'Access to read project\'s API keys',
],
"keys.write" => [
"description" =>
"Access to create, update, and delete project\'s API keys",
],
"devKeys.read" => [
"description" => 'Access to read project\'s development keys',
],
+40
View File
@@ -188,4 +188,44 @@ return [ // List of publicly visible scopes
"description" =>
"Access to update project\'s information",
],
"keys.read" => [
"description" =>
"Access to read project\'s keys",
],
"keys.write" => [
"description" =>
"Access to create, update, and delete project\'s keys",
],
"platforms.read" => [
"description" =>
"Access to read project\'s platforms",
],
"platforms.write" => [
"description" =>
"Access to create, update, and delete project\'s platforms",
],
"mocks.read" => [
"description" =>
"Access to read project\'s mocks",
],
"mocks.write" => [
"description" =>
"Access to create, update, and delete project\'s mocks",
],
"policies.read" => [
"description" =>
"Access to read project\'s policies",
],
"policies.write" => [
"description" =>
"Access to update project\'s policies",
],
"templates.read" => [
"description" =>
"Access to read project\'s templates",
],
"templates.write" => [
"description" =>
"Access to create, update, and delete project\'s templates",
],
];
+20
View File
@@ -300,6 +300,26 @@ return [
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/cursor-plugin/CHANGELOG.md'),
],
[
'key' => 'claude-plugin',
'name' => 'ClaudePlugin',
'version' => '0.1.0',
'url' => 'https://github.com/appwrite/claude-plugin.git',
'enabled' => true,
'beta' => false,
'dev' => false,
'hidden' => false,
'spec' => 'static',
'family' => APP_SDK_PLATFORM_STATIC,
'prism' => 'claude-plugin',
'source' => \realpath(__DIR__ . '/../sdks/static-claude-plugin'),
'gitUrl' => 'git@github.com:appwrite/claude-plugin.git',
'gitRepoName' => 'claude-plugin',
'gitUserName' => 'appwrite',
'gitBranch' => 'dev',
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
],
],
],
+4 -4
View File
@@ -137,7 +137,7 @@ return [
'docs' => true,
'docsUrl' => '',
'tests' => false,
'optional' => false,
'optional' => true,
'icon' => '',
'platforms' => ['client', 'server', 'console'],
],
@@ -193,7 +193,7 @@ return [
'docs' => false,
'docsUrl' => '',
'tests' => false,
'optional' => false,
'optional' => true,
'icon' => '',
'platforms' => ['client', 'server', 'console'],
],
@@ -235,7 +235,7 @@ return [
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/proxy',
'tests' => false,
'optional' => false,
'optional' => true,
'icon' => '/images/services/proxy.png',
'platforms' => ['client', 'server', 'console'],
],
@@ -291,7 +291,7 @@ return [
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/migrations',
'tests' => true,
'optional' => false,
'optional' => true,
'icon' => '/images/services/migrations.png',
'platforms' => ['client', 'server', 'console'],
],
+6 -6
View File
@@ -1487,13 +1487,13 @@ return [
]
],
[
'key' => 'crm-dashboard-react-admin',
'name' => 'CRM dashboard with React Admin',
'tagline' => 'A React-based admin dashboard template with CRM features.',
'key' => 'dashboard-react-admin',
'name' => 'E-commerce dashboard with React Admin',
'tagline' => 'A React-based admin dashboard template with e-commerce features.',
'score' => 4, // 0 to 10 based on looks of screenshot (avoid 1,2,3,8,9,10 if possible)
'useCases' => [SiteUseCases::DASHBOARD],
'screenshotDark' => $url . '/images/sites/templates/crm-dashboard-react-admin-dark.png',
'screenshotLight' => $url . '/images/sites/templates/crm-dashboard-react-admin-light.png',
'useCases' => [SiteUseCases::DASHBOARD, SiteUseCases::ECOMMERCE],
'screenshotDark' => $url . '/images/sites/templates/dashboard-react-admin-dark.png',
'screenshotLight' => $url . '/images/sites/templates/dashboard-react-admin-light.png',
'frameworks' => [
getFramework('REACT', [
'providerRootDirectory' => './react/react-admin',
+4 -4
View File
@@ -872,18 +872,18 @@ return [
],
[
'name' => '_APP_FUNCTIONS_BUILD_TIMEOUT',
'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 900 seconds.',
'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 2700 seconds.',
'introduction' => '0.13.0',
'default' => '900',
'default' => '2700',
'required' => false,
'question' => '',
'filter' => ''
],
[
'name' => '_APP_COMPUTE_BUILD_TIMEOUT',
'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 900 seconds.',
'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 2700 seconds.',
'introduction' => '1.7.0',
'default' => '900',
'default' => '2700',
'required' => false,
'question' => '',
'filter' => ''
+198 -277
View File
@@ -9,6 +9,7 @@ use Appwrite\Auth\Validator\PasswordDictionary;
use Appwrite\Auth\Validator\PasswordHistory;
use Appwrite\Auth\Validator\PersonalData;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Bus\Events\SessionCreated;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
@@ -41,6 +42,7 @@ use Utopia\Auth\Proofs\Code as ProofsCode;
use Utopia\Auth\Proofs\Password as ProofsPassword;
use Utopia\Auth\Proofs\Token as ProofsToken;
use Utopia\Auth\Store;
use Utopia\Bus\Bus;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@@ -75,139 +77,8 @@ use Utopia\Validator\WhiteList;
$oauthDefaultSuccess = '/console/auth/oauth2/success';
$oauthDefaultFailure = '/console/auth/oauth2/failure';
function sendSessionAlert(Locale $locale, Document $user, Document $project, array $platform, Document $session, Mail $queueForMails)
{
$subject = $locale->getText("emails.sessionAlert.subject");
$preview = $locale->getText("emails.sessionAlert.preview");
$customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
if (!$validator->isValid($smtpBaseTemplate)) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path');
}
$bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl';
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl');
$message
->setParam('{{hello}}', $locale->getText("emails.sessionAlert.hello"))
->setParam('{{body}}', $locale->getText("emails.sessionAlert.body"))
->setParam('{{listDevice}}', $locale->getText("emails.sessionAlert.listDevice"))
->setParam('{{listIpAddress}}', $locale->getText("emails.sessionAlert.listIpAddress"))
->setParam('{{listCountry}}', $locale->getText("emails.sessionAlert.listCountry"))
->setParam('{{footer}}', $locale->getText("emails.sessionAlert.footer"))
->setParam('{{thanks}}', $locale->getText("emails.sessionAlert.thanks"))
->setParam('{{signature}}', $locale->getText("emails.sessionAlert.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);
}
// session alerts should always have a client name!
$clientName = $session->getAttribute('clientName');
if (empty($clientName)) {
// fallback to the user agent and then unknown!
$userAgent = $session->getAttribute('userAgent');
$clientName = !empty($userAgent) ? $userAgent : 'UNKNOWN';
$session->setAttribute('clientName', $clientName);
}
$projectName = $project->getAttribute('name');
if ($project->getId() === 'console') {
$projectName = $platform['platformName'];
}
$emailVariables = [
'direction' => $locale->getText('settings.direction'),
'date' => (new \DateTime())->format('F j'),
'year' => (new \DateTime())->format('YYYY'),
'time' => (new \DateTime())->format('H:i:s'),
'user' => $user->getAttribute('name'),
'project' => $projectName,
'device' => $session->getAttribute('clientName'),
'ipAddress' => $session->getAttribute('ip'),
'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')),
];
if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
$emailVariables = array_merge($emailVariables, [
'accentColor' => $platform['accentColor'],
'logoUrl' => $platform['logoUrl'],
'twitter' => $platform['twitterUrl'],
'discord' => $platform['discordUrl'],
'github' => $platform['githubUrl'],
'terms' => $platform['termsUrl'],
'privacy' => $platform['privacyUrl'],
'platform' => $platform['platformName'],
]);
}
$email = $user->getAttribute('email');
$queueForMails
->setSubject($subject)
->setPreview($preview)
->setBody($body)
->setBodyTemplate($bodyTemplate)
->appendVariables($emailVariables)
->setRecipient($email);
// since this is console project, set email sender name!
if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
$queueForMails->setSenderName($platform['emailSenderName']);
}
$queueForMails->trigger();
}
$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) {
$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
// Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info)
$oauthProvider = null;
@@ -262,9 +133,6 @@ $createSession = function (string $userId, string $secret, Request $request, Res
});
$provider = match ($verifiedToken->getAttribute('type')) {
TOKEN_TYPE_VERIFICATION,
TOKEN_TYPE_RECOVERY,
TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL,
TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL,
TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE,
TOKEN_TYPE_OAUTH2 => $oauthProvider,
@@ -318,23 +186,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
}
$isAllowedTokenType = match ($verifiedToken->getAttribute('type')) {
TOKEN_TYPE_MAGIC_URL,
TOKEN_TYPE_EMAIL => false,
default => true
};
$hasUserEmail = $user->getAttribute('email', false) !== false;
$isSessionAlertsEnabled = $project->getAttribute('auths', [])['sessionAlerts'] ?? false;
$isNotFirstSession = $dbForProject->count('sessions', [
Query::equal('userId', [$user->getId()]),
]) !== 1;
if ($isAllowedTokenType && $hasUserEmail && $isSessionAlertsEnabled && $isNotFirstSession) {
sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails);
}
$bus->dispatch(new SessionCreated(
user: $user->getArrayCopy(),
project: $project->getArrayCopy(),
session: $session->getArrayCopy(),
locale: $locale->default,
));
$queueForEvents
->setParam('userId', $user->getId())
@@ -345,7 +202,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
->setProperty('secret', $sessionSecret)
->encode();
if (!Config::getParam('domainVerification')) {
if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
@@ -353,8 +210,8 @@ $createSession = function (string $userId, string $secret, Request $request, Res
$protocol = $request->getProtocol();
$response
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED);
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
@@ -475,15 +332,15 @@ Http::post('/v1/account')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -593,7 +450,7 @@ Http::delete('/v1/account')
->groups(['api', 'account'])
->label('scope', 'account')
->label('audits.event', 'user.delete')
->label('audits.resource', 'user/{response.$id}')
->label('audits.resource', 'user/{user.$id}')
->label('sdk', new Method(
namespace: 'account',
group: 'account',
@@ -614,19 +471,26 @@ Http::delete('/v1/account')
->inject('dbForProject')
->inject('queueForEvents')
->inject('queueForDeletes')
->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) {
->inject('authorization')
->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) {
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
if ($project->getId() === 'console') {
// get all memberships
$memberships = $user->getAttribute('memberships', []);
foreach ($memberships as $membership) {
// prevent deletion if at least one active membership
if ($membership->getAttribute('confirm', false)) {
throw new Exception(Exception::USER_DELETION_PROHIBITED);
if (!$membership->getAttribute('confirm', false)) {
continue;
}
$team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId'));
if ($team->isEmpty()) {
continue;
}
// Team is left as-is — we don't promote non-owner members to owner.
// Orphan teams are cleaned up later by Cloud's inactive project cleanup.
}
}
@@ -719,7 +583,9 @@ Http::delete('/v1/account/sessions')
->inject('queueForDeletes')
->inject('store')
->inject('proofForToken')
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) {
->inject('domainVerification')
->inject('cookieDomain')
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessions = $user->getAttribute('sessions', []);
@@ -728,7 +594,7 @@ Http::delete('/v1/account/sessions')
foreach ($sessions as $session) {/** @var Document $session */
$dbForProject->deleteDocument('sessions', $session->getId());
if (!Config::getParam('domainVerification')) {
if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
@@ -741,8 +607,8 @@ Http::delete('/v1/account/sessions')
// If current session delete the cookies too
$response
->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
// Use current session for events.
$currentSession = $session;
@@ -849,7 +715,9 @@ Http::delete('/v1/account/sessions/:sessionId')
->inject('queueForDeletes')
->inject('store')
->inject('proofForToken')
->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) {
->inject('domainVerification')
->inject('cookieDomain')
->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessionId = ($sessionId === 'current')
@@ -875,13 +743,13 @@ Http::delete('/v1/account/sessions/:sessionId')
->setAttribute('current', true)
->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')));
if (!Config::getParam('domainVerification')) {
if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
$response
->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
$dbForProject->purgeCachedDocument('users', $user->getId());
@@ -966,7 +834,7 @@ Http::patch('/v1/account/sessions/:sessionId')
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
}
if (!empty($provider) && $className !== null && \class_exists($className)) {
if (!empty($provider) && \class_exists($className)) {
$appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? '';
$appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}';
@@ -1030,13 +898,15 @@ Http::post('/v1/account/sessions/email')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->inject('bus')
->inject('hooks')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
->inject('domainVerification')
->inject('cookieDomain')
->inject('authorization')
->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) {
->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
$email = \strtolower($email);
$protocol = $request->getProtocol();
@@ -1097,28 +967,28 @@ Http::post('/v1/account/sessions/email')
]));
}
$dbForProject->purgeCachedDocument('users', $user->getId());
$session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]));
$dbForProject->purgeCachedDocument('users', $user->getId());
$encoded = $store
->setProperty('id', $user->getId())
->setProperty('secret', $secret)
->encode();
if (!Config::getParam('domainVerification')) {
if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$response
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
@@ -1135,15 +1005,12 @@ Http::post('/v1/account/sessions/email')
->setParam('sessionId', $session->getId())
;
if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) {
if (
$dbForProject->count('sessions', [
Query::equal('userId', [$user->getId()]),
]) !== 1
) {
sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails);
}
}
$bus->dispatch(new SessionCreated(
user: $user->getArrayCopy(),
project: $project->getArrayCopy(),
session: $session->getArrayCopy(),
locale: $locale->default,
));
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -1184,8 +1051,10 @@ Http::post('/v1/account/sessions/anonymous')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
->inject('domainVerification')
->inject('cookieDomain')
->inject('authorization')
->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) {
->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
$protocol = $request->getProtocol();
if ('console' === $project->getId()) {
@@ -1276,15 +1145,15 @@ Http::post('/v1/account/sessions/anonymous')
->setProperty('secret', $secret)
->encode();
if (!Config::getParam('domainVerification')) {
if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$response
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
@@ -1335,11 +1204,13 @@ Http::post('/v1/account/sessions/token')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->inject('bus')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
->inject('authorization')
->inject('domainVerification')
->inject('cookieDomain')
->inject('authorization')
->action($createSession);
Http::get('/v1/account/sessions/oauth2/:provider')
@@ -1538,8 +1409,10 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->inject('proofForPassword')
->inject('proofForToken')
->inject('plan')
->inject('domainVerification')
->inject('cookieDomain')
->inject('authorization')
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, Authorization $authorization) use ($oauthDefaultSuccess) {
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) use ($oauthDefaultSuccess) {
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$callbackBase = $protocol . '://' . $request->getHostname();
@@ -1728,7 +1601,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
if ($user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
if (empty($email)) {
$failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.');
}
@@ -1745,7 +1618,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
// If user is not found, check if there is a user with the same email
if ($user === false || $user->isEmpty()) {
if ($user->isEmpty()) {
$userWithEmail = $dbForProject->findOne('users', [
Query::equal('email', [$email]),
]);
@@ -1758,7 +1631,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
// If user is not found, check if there is an identity with the same email
if ($user === false || $user->isEmpty()) {
if ($user->isEmpty()) {
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
Query::equal('providerEmail', [$email]),
]);
@@ -1770,7 +1643,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
if ($user === false || $user->isEmpty()) { // Last option -> create the user
if ($user->isEmpty()) { // Last option -> create the user
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
if ($limit !== 0) {
@@ -1803,15 +1676,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -1944,15 +1817,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -2055,7 +1928,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->setProperty('secret', $secret)
->encode();
if (!Config::getParam('domainVerification')) {
if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
@@ -2068,17 +1941,17 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
// TODO: Remove this deprecated workaround - support only token
if ($state['success']['path'] == $oauthDefaultSuccess) {
$query['project'] = $project->getId();
$query['domain'] = Config::getParam('cookieDomain');
$query['domain'] = $cookieDomain;
$query['key'] = $store->getKey();
$query['secret'] = $encoded;
}
$response
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) {
if (isset($sessionUpgrade) && isset($session)) {
foreach ($user->getAttribute('targets', []) as $target) {
if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) {
continue;
@@ -2173,7 +2046,7 @@ Http::get('/v1/account/tokens/oauth2/:provider')
}
$host = $platform['consoleHostname'] ?? '';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$redirectBase = $protocol . '://' . $host;
if ($protocol === 'https' && $port !== '443') {
@@ -2196,10 +2069,12 @@ Http::get('/v1/account/tokens/oauth2/:provider')
'token' => true,
], $scopes);
$loginURL = $oauth2->getLoginURL();
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->redirect($oauth2->getLoginURL());
->redirect($loginURL);
});
Http::post('/v1/account/tokens/magic-url')
@@ -2300,15 +2175,15 @@ Http::post('/v1/account/tokens/magic-url')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2394,7 +2269,10 @@ Http::post('/v1/account/tokens/magic-url')
$subject = $locale->getText("emails.magicSession.subject");
$preview = $locale->getText("emails.magicSession.preview");
$customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? [];
$customTemplate =
$project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ??
$project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? [];
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$agentOs = $detector->getOS();
@@ -2424,8 +2302,8 @@ Http::post('/v1/account/tokens/magic-url')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
$replyToEmail = '';
$replyToName = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2434,8 +2312,13 @@ Http::post('/v1/account/tokens/magic-url')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
if (!empty($smtpReplyToEmail)) {
$replyToEmail = $smtpReplyToEmail;
}
if (!empty($smtp['replyToName'])) {
$replyToName = $smtp['replyToName'];
}
$queueForMails
@@ -2452,8 +2335,13 @@ Http::post('/v1/account/tokens/magic-url')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
if (!empty($customTemplate['replyTo'])) {
$replyTo = $customTemplate['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
if (!empty($customReplyToEmail)) {
$replyToEmail = $customReplyToEmail;
}
if (!empty($customTemplate['replyToName'])) {
$replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
@@ -2461,7 +2349,8 @@ Http::post('/v1/account/tokens/magic-url')
}
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpReplyToEmail($replyToEmail)
->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -2607,15 +2496,15 @@ Http::post('/v1/account/tokens/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2704,7 +2593,9 @@ Http::post('/v1/account/tokens/email')
$preview = $locale->getText("emails.otpSession.preview");
$heading = $locale->getText("emails.otpSession.heading");
$customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? [];
$customTemplate =
$project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ??
$project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -2740,7 +2631,8 @@ Http::post('/v1/account/tokens/email')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
$replyToEmail = '';
$replyToName = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2749,8 +2641,13 @@ Http::post('/v1/account/tokens/email')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
if (!empty($smtpReplyToEmail)) {
$replyToEmail = $smtpReplyToEmail;
}
if (!empty($smtp['replyToName'])) {
$replyToName = $smtp['replyToName'];
}
$queueForMails
@@ -2767,8 +2664,13 @@ Http::post('/v1/account/tokens/email')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
if (!empty($customTemplate['replyTo'])) {
$replyTo = $customTemplate['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
if (!empty($customReplyToEmail)) {
$replyToEmail = $customReplyToEmail;
}
if (!empty($customTemplate['replyToName'])) {
$replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
@@ -2776,7 +2678,8 @@ Http::post('/v1/account/tokens/email')
}
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpReplyToEmail($replyToEmail)
->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -2883,14 +2786,16 @@ Http::put('/v1/account/sessions/magic-url')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->inject('bus')
->inject('store')
->inject('proofForCode')
->inject('domainVerification')
->inject('cookieDomain')
->inject('authorization')
->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $authorization) use ($createSession) {
->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) {
$proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL);
$proofForToken->setHash(new Sha());
$createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $authorization);
$createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization);
});
Http::put('/v1/account/sessions/phone')
@@ -2932,10 +2837,12 @@ Http::put('/v1/account/sessions/phone')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->inject('bus')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
->inject('domainVerification')
->inject('cookieDomain')
->inject('authorization')
->action($createSession);
@@ -3093,11 +3000,6 @@ Http::post('/v1/account/tokens/phone')
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
}
$projectName = $project->getAttribute('name');
if ($project->getId() === 'console') {
$projectName = $platform['platformName'];
@@ -3392,7 +3294,7 @@ Http::patch('/v1/account/password')
}
$history[] = $newPassword;
$history = array_slice($history, (count($history) - $historyLimit), $historyLimit);
$history = array_slice($history, -$historyLimit);
}
if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) {
@@ -3515,15 +3417,15 @@ Http::patch('/v1/account/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -3555,9 +3457,6 @@ Http::patch('/v1/account/email')
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
/**
* @var Document $oldTarget
*/
$oldTarget = $user->find('identifier', $oldEmail, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -3644,9 +3543,6 @@ Http::patch('/v1/account/phone')
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
/**
* @var Document $oldTarget
*/
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -3727,7 +3623,9 @@ Http::patch('/v1/account/status')
->inject('dbForProject')
->inject('queueForEvents')
->inject('store')
->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store) {
->inject('domainVerification')
->inject('cookieDomain')
->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store, bool $domainVerification, ?string $cookieDomain) {
$user->setAttribute('status', false);
@@ -3737,14 +3635,14 @@ Http::patch('/v1/account/status')
->setParam('userId', $user->getId())
->setPayload($response->output($user, Response::MODEL_ACCOUNT));
if (!Config::getParam('domainVerification')) {
if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
$protocol = $request->getProtocol();
$response
->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
;
$response->dynamic($user, Response::MODEL_ACCOUNT);
@@ -3849,7 +3747,9 @@ Http::post('/v1/account/recovery')
$body = $locale->getText("emails.recovery.body");
$subject = $locale->getText("emails.recovery.subject");
$preview = $locale->getText("emails.recovery.preview");
$customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? [];
$customTemplate =
$project->getAttribute('templates', [])['email.recovery-' . $locale->default] ??
$project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? [];
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl');
$message
@@ -3866,7 +3766,8 @@ Http::post('/v1/account/recovery')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
$replyToEmail = '';
$replyToName = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -3875,8 +3776,13 @@ Http::post('/v1/account/recovery')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
if (!empty($smtpReplyToEmail)) {
$replyToEmail = $smtpReplyToEmail;
}
if (!empty($smtp['replyToName'])) {
$replyToName = $smtp['replyToName'];
}
$queueForMails
@@ -3893,8 +3799,13 @@ Http::post('/v1/account/recovery')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
if (!empty($customTemplate['replyTo'])) {
$replyTo = $customTemplate['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
if (!empty($customReplyToEmail)) {
$replyToEmail = $customReplyToEmail;
}
if (!empty($customTemplate['replyToName'])) {
$replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
@@ -3902,7 +3813,8 @@ Http::post('/v1/account/recovery')
}
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpReplyToEmail($replyToEmail)
->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -4157,7 +4069,9 @@ Http::post('/v1/account/verifications/email')
$subject = $locale->getText("emails.verification.subject");
$heading = $locale->getText("emails.verification.heading");
$customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? [];
$customTemplate =
$project->getAttribute('templates', [])['email.verification-' . $locale->default] ??
$project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -4183,7 +4097,8 @@ Http::post('/v1/account/verifications/email')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
$replyToEmail = '';
$replyToName = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -4192,8 +4107,13 @@ Http::post('/v1/account/verifications/email')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
if (!empty($smtpReplyToEmail)) {
$replyToEmail = $smtpReplyToEmail;
}
if (!empty($smtp['replyToName'])) {
$replyToName = $smtp['replyToName'];
}
$queueForMails
@@ -4210,8 +4130,13 @@ Http::post('/v1/account/verifications/email')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
if (!empty($customTemplate['replyTo'])) {
$replyTo = $customTemplate['replyTo'];
// Includes backwards compatibility: fall back to legacy `replyTo` key
$customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
if (!empty($customReplyToEmail)) {
$replyToEmail = $customReplyToEmail;
}
if (!empty($customTemplate['replyToName'])) {
$replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
@@ -4219,7 +4144,8 @@ Http::post('/v1/account/verifications/email')
}
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpReplyToEmail($replyToEmail)
->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -4456,11 +4382,6 @@ Http::post('/v1/account/verifications/phone')
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
@@ -4735,7 +4656,7 @@ Http::delete('/v1/account/targets/:targetId/push')
->groups(['api', 'account'])
->label('scope', 'targets.write')
->label('audits.event', 'target.delete')
->label('audits.resource', 'target/response.$id')
->label('audits.resource', 'target/{request.targetId}')
->label('event', 'users.[userId].targets.[targetId].delete')
->label('sdk', new Method(
namespace: 'account',
+1 -1
View File
@@ -231,7 +231,7 @@ function execute(
$validations = GraphQL::getStandardValidationRules();
if (System::getEnv('_APP_GRAPHQL_INTROSPECTION', 'enabled') === 'disabled') {
$validations[] = new DisableIntrospection();
$validations[] = new DisableIntrospection(DisableIntrospection::ENABLED);
}
if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled') {
+5 -14
View File
@@ -482,7 +482,6 @@ Http::post('/v1/messaging/providers/msg91')
$enabled === true
&& \array_key_exists('senderId', $credentials)
&& \array_key_exists('authKey', $credentials)
&& \array_key_exists('from', $options)
) {
$enabled = true;
} else {
@@ -1180,6 +1179,7 @@ Http::get('/v1/messaging/providers/:providerId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -2585,6 +2585,7 @@ Http::get('/v1/messaging/topics/:topicId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -3000,6 +3001,7 @@ Http::get('/v1/messaging/subscribers/:subscriberId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -3204,10 +3206,6 @@ Http::post('/v1/messaging/messages/email')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
}
$mergedTargets = \array_merge($targets, $cc, $bcc);
if (!empty($mergedTargets)) {
@@ -3383,10 +3381,6 @@ Http::post('/v1/messaging/messages/sms')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
}
if (!empty($targets)) {
$foundTargets = $dbForProject->find('targets', [
Query::equal('$id', $targets),
@@ -3524,10 +3518,6 @@ Http::post('/v1/messaging/messages/push')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
}
if (!empty($targets)) {
$foundTargets = $dbForProject->find('targets', [
Query::equal('$id', $targets),
@@ -3813,6 +3803,7 @@ Http::get('/v1/messaging/messages/:messageId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -4656,7 +4647,7 @@ Http::delete('/v1/messaging/messages/:messageId')
if (!empty($scheduleId)) {
try {
$dbForPlatform->deleteDocument('schedules', $scheduleId);
} catch (Exception) {
} catch (\Throwable) {
// Ignore
}
}
+66 -75
View File
@@ -1,7 +1,8 @@
<?php
use Appwrite\Event\Event;
use Appwrite\Event\Migration;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Extend\Exception;
use Appwrite\OpenSSL\OpenSSL;
use Appwrite\SDK\AuthType;
@@ -50,7 +51,8 @@ function getDatabaseTransferResourceServices(string $databaseType)
DATABASE_TYPE_LEGACY,
DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB
DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
default => throw new \LogicException('Unknown database type: ' . $databaseType),
};
}
@@ -90,10 +92,9 @@ Http::post('/v1/migrations/appwrite')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('user')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
->inject('publisherForMigrations')
->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) {
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
@@ -114,12 +115,11 @@ Http::post('/v1/migrations/appwrite')
$queueForEvents->setParam('migrationId', $migration->getId());
// Trigger Transfer
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->setUser($user)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -151,10 +151,9 @@ Http::post('/v1/migrations/firebase')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('user')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
->inject('publisherForMigrations')
->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) {
$serviceAccountData = json_decode($serviceAccount, true);
if (empty($serviceAccountData)) {
@@ -183,12 +182,11 @@ Http::post('/v1/migrations/firebase')
$queueForEvents->setParam('migrationId', $migration->getId());
// Trigger Transfer
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->setUser($user)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -225,10 +223,9 @@ Http::post('/v1/migrations/supabase')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('user')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
->inject('publisherForMigrations')
->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) {
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
@@ -252,12 +249,11 @@ Http::post('/v1/migrations/supabase')
$queueForEvents->setParam('migrationId', $migration->getId());
// Trigger Transfer
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->setUser($user)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -295,10 +291,9 @@ Http::post('/v1/migrations/nhost')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('user')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
->inject('publisherForMigrations')
->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) {
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
@@ -323,12 +318,11 @@ Http::post('/v1/migrations/nhost')
$queueForEvents->setParam('migrationId', $migration->getId());
// Trigger Transfer
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->setUser($user)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -368,7 +362,7 @@ Http::post('/v1/migrations/csv/imports')
->inject('deviceForFiles')
->inject('deviceForMigrations')
->inject('queueForEvents')
->inject('queueForMigrations')
->inject('publisherForMigrations')
->action(function (
string $bucketId,
string $fileId,
@@ -383,7 +377,7 @@ Http::post('/v1/migrations/csv/imports')
Device $deviceForFiles,
Device $deviceForMigrations,
Event $queueForEvents,
Migration $queueForMigrations
MigrationPublisher $publisherForMigrations
) {
$bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
if ($internalFile) {
@@ -479,11 +473,10 @@ Http::post('/v1/migrations/csv/imports')
$queueForEvents->setParam('migrationId', $migration->getId());
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setProject($project)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -526,7 +519,7 @@ Http::post('/v1/migrations/csv/exports')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('queueForMigrations')
->inject('publisherForMigrations')
->action(function (
string $resourceId,
string $filename,
@@ -545,7 +538,7 @@ Http::post('/v1/migrations/csv/exports')
Document $project,
array $platform,
Event $queueForEvents,
Migration $queueForMigrations
MigrationPublisher $publisherForMigrations
) {
try {
$parsedQueries = Query::parseQueries($queries);
@@ -630,11 +623,11 @@ Http::post('/v1/migrations/csv/exports')
$queueForEvents->setParam('migrationId', $migration->getId());
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -673,7 +666,7 @@ Http::post('/v1/migrations/json/imports')
->inject('deviceForFiles')
->inject('deviceForMigrations')
->inject('queueForEvents')
->inject('queueForMigrations')
->inject('publisherForMigrations')
->action(function (
string $bucketId,
string $fileId,
@@ -688,7 +681,7 @@ Http::post('/v1/migrations/json/imports')
Device $deviceForFiles,
Device $deviceForMigrations,
Event $queueForEvents,
Migration $queueForMigrations
MigrationPublisher $publisherForMigrations
) {
$bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
if ($internalFile) {
@@ -783,11 +776,11 @@ Http::post('/v1/migrations/json/imports')
$queueForEvents->setParam('migrationId', $migration->getId());
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -826,7 +819,7 @@ Http::post('/v1/migrations/json/exports')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('queueForMigrations')
->inject('publisherForMigrations')
->action(function (
string $resourceId,
string $filename,
@@ -841,7 +834,7 @@ Http::post('/v1/migrations/json/exports')
Document $project,
array $platform,
Event $queueForEvents,
Migration $queueForMigrations
MigrationPublisher $publisherForMigrations
) {
try {
$parsedQueries = Query::parseQueries($queries);
@@ -915,11 +908,11 @@ Http::post('/v1/migrations/json/exports')
$queueForEvents->setParam('migrationId', $migration->getId());
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@@ -1216,9 +1209,8 @@ Http::patch('/v1/migrations/:migrationId')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('user')
->inject('queueForMigrations')
->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Migration $queueForMigrations) {
->inject('publisherForMigrations')
->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, MigrationPublisher $publisherForMigrations) {
$migration = $dbForProject->getDocument('migrations', $migrationId);
if ($migration->isEmpty()) {
@@ -1234,12 +1226,11 @@ Http::patch('/v1/migrations/:migrationId')
->setAttribute('dateUpdated', \time());
// Trigger Migration
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->setUser($user)
->trigger();
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response->noContent();
});
+2 -1
View File
@@ -113,11 +113,12 @@ Http::get('/v1/project/usage')
$factor = match ($period) {
'1h' => 3600,
'1d' => 86400,
default => throw new \LogicException('Unsupported period: ' . $period),
};
$limit = match ($period) {
'1h' => (new DateTime($startDate))->diff(new DateTime($endDate))->days * 24,
'1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days
'1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days,
};
$format = match ($period) {
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -1008,6 +1008,8 @@ Http::get('/v1/users/:userId/logs')
'userId' => ID::custom($log['data']['userId']),
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -1533,7 +1535,7 @@ Http::patch('/v1/users/:userId/email')
Query::equal('identifier', [$email]),
]);
if ($target instanceof Document && !$target->isEmpty()) {
if (!$target->isEmpty()) {
throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS);
}
}
@@ -1593,9 +1595,6 @@ Http::patch('/v1/users/:userId/email')
'emailIsDisposable' => $user->getAttribute('emailIsDisposable'),
'emailIsFree' => $user->getAttribute('emailIsFree'),
]));
/**
* @var Document $oldTarget
*/
$oldTarget = $user->find('identifier', $oldEmail, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -1679,7 +1678,7 @@ Http::patch('/v1/users/:userId/phone')
Query::equal('identifier', [$number]),
]);
if ($target instanceof Document && !$target->isEmpty()) {
if (!$target->isEmpty()) {
throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS);
}
}
@@ -1689,9 +1688,6 @@ Http::patch('/v1/users/:userId/phone')
'phone' => $phoneValue,
'phoneVerification' => $user->getAttribute('phoneVerification'),
]));
/**
* @var Document $oldTarget
*/
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -2250,8 +2246,8 @@ Http::delete('/v1/users/:userId/mfa/authenticators/:type')
->label('event', 'users.[userId].delete.mfa')
->label('scope', 'users.write')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('audits.userId', '{response.$id}')
->label('audits.resource', 'user/{request.userId}')
->label('audits.userId', '{request.userId}')
->label('usage.metric', 'users.{scope}.requests.update')
->label('sdk', [
new Method(
@@ -2840,6 +2836,7 @@ Http::get('/v1/users/usage')
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
default => throw new \LogicException('Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
+63 -86
View File
@@ -7,9 +7,9 @@ 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\Publisher\Certificate;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Network\Cors;
use Appwrite\Platform\Appwrite;
@@ -25,6 +25,8 @@ use Appwrite\Utopia\Request\Filters\V18 as RequestV18;
use Appwrite\Utopia\Request\Filters\V19 as RequestV19;
use Appwrite\Utopia\Request\Filters\V20 as RequestV20;
use Appwrite\Utopia\Request\Filters\V21 as RequestV21;
use Appwrite\Utopia\Request\Filters\V22 as RequestV22;
use Appwrite\Utopia\Request\Filters\V23 as RequestV23;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -32,6 +34,8 @@ use Appwrite\Utopia\Response\Filters\V18 as ResponseV18;
use Appwrite\Utopia\Response\Filters\V19 as ResponseV19;
use Appwrite\Utopia\Response\Filters\V20 as ResponseV20;
use Appwrite\Utopia\Response\Filters\V21 as ResponseV21;
use Appwrite\Utopia\Response\Filters\V22 as ResponseV22;
use Appwrite\Utopia\Response\Filters\V23 as ResponseV23;
use Appwrite\Utopia\View;
use Executor\Executor;
use MaxMind\Db\Reader;
@@ -61,13 +65,11 @@ use Utopia\System\System;
use Utopia\Validator;
use Utopia\Validator\Text;
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, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
{
$host = $request->getHostname() ?? '';
$host = $request->getHostname();
if (!empty($previewHostname)) {
$host = $previewHostname;
}
@@ -200,12 +202,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId));
}
if ($deployment->getAttribute('resourceType', '') === 'functions') {
$type = 'function';
} elseif ($deployment->getAttribute('resourceType', '') === 'sites') {
$type = 'site';
}
if ($deployment->isEmpty()) {
$resourceType = $rule->getAttribute('deploymentResourceType', '');
$resourceId = $rule->getAttribute('deploymentResourceId', '');
@@ -215,6 +211,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
throw $exception;
}
if ($deployment->getAttribute('resourceType', '') === 'functions') {
$type = 'function';
} elseif ($deployment->getAttribute('resourceType', '') === 'sites') {
$type = 'site';
} else {
throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown deployment resource type', view: $errorView);
}
$resource = $type === 'function' ?
$authorization->skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) :
$authorization->skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', '')));
@@ -302,13 +306,13 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
}
$body = $swooleRequest->getContent() ?? '';
$body = $swooleRequest->getContent() ?: '';
$method = $swooleRequest->server['request_method'];
$requestHeaders = $request->getHeaders();
if ($resource->isEmpty() || !$resource->getAttribute('enabled')) {
if ($type === 'functions') {
if ($type === 'function') {
throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView);
} else {
throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView);
@@ -330,7 +334,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$runtime = match ($type) {
'function' => $runtimes[$resource->getAttribute('runtime')] ?? null,
'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null,
default => null
};
// Static site enforced runtime
@@ -459,10 +462,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
// V2 vars
if ($version === 'v2') {
$vars = \array_merge($vars, [
'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'],
'APPWRITE_FUNCTION_DATA' => $body,
'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'],
'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt']
]);
}
@@ -678,9 +681,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (\is_string($logs) && \strlen($logs) > $maxLogLength) {
$warningMessage = "[WARNING] Logs truncated. The output exceeded {$maxLogLength} characters.\n";
$warningLength = \strlen($warningMessage);
$maxContentLength = max(0, $maxLogLength - $warningLength);
$logs = $warningMessage . ($maxContentLength > 0 ? \substr($logs, -$maxContentLength) : '');
$maxContentLength = $maxLogLength - \strlen($warningMessage);
$logs = $warningMessage . \substr($logs, -$maxContentLength);
}
// Truncate errors if they exceed the limit
@@ -689,9 +691,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (\is_string($errors) && \strlen($errors) > $maxErrorLength) {
$warningMessage = "[WARNING] Errors truncated. The output exceeded {$maxErrorLength} characters.\n";
$warningLength = \strlen($warningMessage);
$maxContentLength = max(0, $maxErrorLength - $warningLength);
$errors = $warningMessage . ($maxContentLength > 0 ? \substr($errors, -$maxContentLength) : '');
$maxContentLength = $maxErrorLength - \strlen($warningMessage);
$errors = $warningMessage . \substr($errors, -$maxContentLength);
}
/** Update execution status */
$status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed';
@@ -719,14 +720,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
throw $th;
}
} finally {
if ($type === 'function' || $type === 'site') {
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
spec: $spec,
resource: $resource->getArrayCopy(),
));
}
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
spec: $spec,
resource: $resource->getArrayCopy(),
));
}
$execution->setAttribute('logs', '');
@@ -754,11 +753,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
if (\is_array($values)) {
$count = 0;
foreach ($values as $value) {
$override = $count === 0;
$response->addHeader($name, $value, override: $override);
$count++;
$response->addHeader($name, $value);
}
} else {
$response->addHeader($name, $values);
@@ -855,7 +851,7 @@ Http::init()
/*
* Appwrite Router
*/
$hostname = $request->getHostname() ?? '';
$hostname = $request->getHostname();
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
@@ -897,6 +893,12 @@ Http::init()
if (version_compare($requestFormat, '1.9.0', '<')) {
$request->addFilter(new RequestV21());
}
if (version_compare($requestFormat, '1.9.1', '<')) {
$request->addFilter(new RequestV22());
}
if (version_compare($requestFormat, '1.9.2', '<')) {
$request->addFilter(new RequestV23());
}
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -904,40 +906,16 @@ Http::init()
$locale->setDefault($localeParam);
}
$origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
$selfDomain = new Domain($request->getHostname());
$endDomain = new Domain((string)$origin);
Config::setParam(
'domainVerification',
($selfDomain->getRegisterable() === $endDomain->getRegisterable()) &&
$endDomain->getRegisterable() !== ''
);
$localHosts = ['localhost','localhost:'.$request->getPort()];
$migrationHost = System::getEnv('_APP_MIGRATION_HOST');
if (!empty($migrationHost)) {
// Treat the migration host like localhost because internal migration and
// CI traffic may use it before a public domain is configured.
$localHosts[] = $migrationHost;
$localHosts[] = $migrationHost.':'.$request->getPort();
}
$isLocalHost = in_array($request->getHostname(), $localHosts);
$isIpAddress = filter_var($request->getHostname(), FILTER_VALIDATE_IP) !== false;
$isConsoleProject = $project->getAttribute('$id', '') === 'console';
$isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled';
Config::setParam(
'cookieDomain',
$isLocalHost || $isIpAddress
? null
: (
$isConsoleProject && $isConsoleRootSession
? '.' . $selfDomain->getRegisterable()
: '.' . $request->getHostname()
)
);
$warnings = [];
/*
@@ -945,6 +923,12 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
if (version_compare($responseFormat, '1.9.2', '<')) {
$response->addFilter(new ResponseV23());
}
if (version_compare($responseFormat, '1.9.1', '<')) {
$response->addFilter(new ResponseV22());
}
if (version_compare($responseFormat, '1.9.0', '<')) {
$response->addFilter(new ResponseV21());
}
@@ -1035,11 +1019,11 @@ Http::init()
->inject('request')
->inject('console')
->inject('dbForPlatform')
->inject('queueForCertificates')
->inject('publisherForCertificates')
->inject('platform')
->inject('authorization')
->inject('certifiedDomains')
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) {
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) {
$hostname = $request->getHostname();
$platformHostnames = $platform['hostnames'] ?? [];
@@ -1065,7 +1049,7 @@ Http::init()
}
// 4. Check/create rule (requires DB access)
$authorization->skip(function () use ($dbForPlatform, $domain, $console, $queueForCertificates, $certifiedDomains) {
$authorization->skip(function () use ($dbForPlatform, $domain, $console, $publisherForCertificates, $certifiedDomains) {
try {
// TODO: (@Meldiron) Remove after 1.7.x migration
$isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
@@ -1121,10 +1105,11 @@ Http::init()
$dbForPlatform->createDocument('rules', $document);
Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...');
$queueForCertificates
->setDomain($document)
->setSkipRenewCheck(true)
->trigger();
$publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
project: $console,
domain: $document,
skipRenewCheck: true,
));
} catch (Duplicate $e) {
Console::info('Certificate already exists');
} finally {
@@ -1197,15 +1182,6 @@ Http::error()
->inject('devKey')
->inject('authorization')
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) {
$trace = $error->getTrace();
foreach (array_slice($trace, 0, 100) as $index => $traceEntry) {
$file = isset($traceEntry['file']) ? $traceEntry['file'] : '[internal function]';
$line = isset($traceEntry['line']) ? $traceEntry['line'] : '';
$function = isset($traceEntry['function']) ? $traceEntry['function'] : '';
Console::error("[$index] $file : $line -> $function()");
}
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$route = $utopia->getRoute();
$class = \get_class($error);
@@ -1215,9 +1191,7 @@ Http::error()
$line = $error->getLine();
$trace = $error->getTrace();
if (php_sapi_name() === 'cli') {
Span::error($error);
}
Span::error($error);
switch ($class) {
case Utopia\Http\Exception::class:
@@ -1459,6 +1433,7 @@ Http::error()
case 402: // Error allowed publicly
case 403: // Error allowed publicly
case 404: // Error allowed publicly
case 405: // Error allowed publicly
case 408: // Error allowed publicly
case 409: // Error allowed publicly
case 412: // Error allowed publicly
@@ -1500,7 +1475,9 @@ Http::error()
try {
$cors = $utopia->getResource('cors');
foreach ($cors->headers($request->getOrigin()) as $name => $value) {
$response->addHeader($name, $value, override: true);
$response
->removeHeader($name)
->addHeader($name, $value);
}
} catch (Throwable) {
// Degrade gracefully - error response without CORS is no worse than before.
@@ -1527,9 +1504,9 @@ Http::error()
->setParam('development', Http::isDevelopment())
->setParam('projectName', $project->getAttribute('name'))
->setParam('projectURL', $project->getAttribute('url'))
->setParam('message', $output['message'] ?? '')
->setParam('type', $output['type'] ?? '')
->setParam('code', $output['code'] ?? '')
->setParam('message', $output['message'])
->setParam('type', $output['type'])
->setParam('code', $output['code'])
->setParam('trace', $output['trace'] ?? [])
->setParam('exception', $error);
@@ -1644,7 +1621,7 @@ Http::get('/.well-known/acme-challenge/*')
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND, 'Unknown path');
}
if (!\substr($absolute, 0, \strlen($base)) === $base) {
if (\substr($absolute, 0, \strlen($base)) !== $base) {
throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, 'Invalid path');
}
@@ -1723,7 +1700,7 @@ Http::get('/_appwrite/authorize')
->inject('previewHostname')
->action(function (Request $request, Response $response, string $previewHostname) {
$host = $request->getHostname() ?? '';
$host = $request->getHostname();
if (!empty($previewHostname)) {
$host = $previewHostname;
}
+1 -1
View File
@@ -251,7 +251,7 @@ Http::get('/v1/mock/github/callback')
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$owner = $github->getOwnerName($providerInstallationId) ?? '';
$owner = $github->getOwnerName($providerInstallationId);
$projectInternalId = $project->getSequence();
+91 -54
View File
@@ -3,15 +3,17 @@
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\Context\Audit as AuditContext;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Message\Audit as AuditMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
use Appwrite\Event\Messaging;
use Appwrite\Event\Publisher\Audit;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
@@ -37,13 +39,14 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
use Utopia\Database\Validator\Roles;
use Utopia\Http\Http;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
use Utopia\Validator\WhiteList;
$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) {
$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user, Document $project) {
preg_match_all('/{(.*?)}/', $label, $matches);
foreach ($matches[1] ?? [] as $pos => $match) {
foreach ($matches[1] as $pos => $match) {
$find = $matches[0][$pos];
$parts = explode('.', $match);
@@ -51,11 +54,12 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose");
}
$namespace = $parts[0] ?? '';
$replace = $parts[1] ?? '';
$namespace = $parts[0];
$replace = $parts[1];
$params = match ($namespace) {
'user' => (array) $user,
'project' => $project->getArrayCopy(),
'request' => $requestParams,
default => $responsePayload,
};
@@ -87,7 +91,7 @@ Http::init()
->inject('request')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('queueForAudits')
->inject('auditContext')
->inject('project')
->inject('user')
->inject('session')
@@ -96,8 +100,11 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('authorization')
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
}
/**
* Handle user authentication and session validation.
@@ -183,13 +190,13 @@ Http::init()
$user = new User([
'$id' => '',
'status' => true,
'type' => ACTIVITY_TYPE_APP,
'type' => ACTIVITY_TYPE_KEY_PROJECT,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $apiKey->getName(),
]);
$queueForAudits->setUser($user);
$auditContext->user = $user;
}
// For standard keys, update last accessed time
@@ -253,7 +260,13 @@ Http::init()
}
}
$queueForAudits->setUser($user);
$userClone = clone $user;
$userClone->setAttribute('type', match ($apiKey->getType()) {
API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT,
API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT,
default => ACTIVITY_TYPE_KEY_ORGANIZATION,
});
$auditContext->user = $userClone;
}
// Apply permission
@@ -372,7 +385,7 @@ Http::init()
}
// Step 6: Update project and user last activity
if (! $project->isEmpty() && $project->getId() !== 'console') {
if ($project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
@@ -402,9 +415,6 @@ Http::init()
}
// Steps 7-9: Access Control - Method, Namespace and Scope Validation
/**
* @var ?Method $method
*/
$method = $route->getLabel('sdk', false);
// Take the first method if there's more than one,
@@ -414,7 +424,7 @@ Http::init()
}
if (! empty($method)) {
$namespace = $method->getNamespace();
$namespace = \strtolower($method->getNamespace());
if (
array_key_exists($namespace, $project->getAttribute('services', []))
@@ -425,6 +435,15 @@ Http::init()
}
}
// Step 8b: Check REST protocol status
if (
array_key_exists('rest', $project->getAttribute('apis', []))
&& ! $project->getAttribute('apis', [])['rest']
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
// Step 9: Validate scope permissions
$allowed = (array) $route->getLabel('scope', 'none');
if (empty(\array_intersect($allowed, $scopes))) {
@@ -466,7 +485,7 @@ Http::init()
->inject('user')
->inject('queueForEvents')
->inject('queueForMessaging')
->inject('queueForAudits')
->inject('auditContext')
->inject('queueForDeletes')
->inject('queueForDatabase')
->inject('queueForBuilds')
@@ -483,12 +502,16 @@ Http::init()
->inject('telemetry')
->inject('platform')
->inject('authorization')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
$response->setUser($user);
$request->setUser($user);
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
}
$path = $route->getMatchedPath();
$databaseType = match (true) {
str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
@@ -496,14 +519,6 @@ Http::init()
default => '',
};
if (
array_key_exists('rest', $project->getAttribute('apis', []))
&& ! $project->getAttribute('apis', [])['rest']
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
/*
* Abuse Check
*/
@@ -580,20 +595,21 @@ Http::init()
->setProject($project)
->setUser($user);
$queueForAudits
->setMode($mode)
->setUserAgent($request->getUserAgent(''))
->setIP($request->getIP())
->setHostname($request->getHostname())
->setEvent($route->getLabel('audits.event', ''))
->setProject($project);
$auditContext->mode = $mode;
$auditContext->userAgent = $request->getUserAgent('');
$auditContext->ip = $request->getIP();
$auditContext->hostname = $request->getHostname();
$auditContext->event = $route->getLabel('audits.event', '');
$auditContext->project = $project;
/* If a session exists, use the user associated with the session */
if (! $user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
$queueForAudits->setUser($userClone);
if (empty($user->getAttribute('type'))) {
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
}
$auditContext->user = $userClone;
}
/* Auto-set projects */
@@ -617,6 +633,7 @@ Http::init()
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles());
$key = $request->cacheIdentifier();
Span::add('storage.cache.key', $key);
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
$cache = new Cache(
new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId())
@@ -626,7 +643,7 @@ Http::init()
if (! empty($data) && ! $cacheLog->isEmpty()) {
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
$type = $parts[0] ?? null;
$type = $parts[0];
if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) {
$bucketId = $parts[1] ?? null;
@@ -665,6 +682,8 @@ Http::init()
if ($file->isEmpty()) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
Span::add('storage.bucket.id', $bucketId);
Span::add('storage.file.id', $fileId);
// Do not update transformedAt if it's a console user
if (! $user->isPrivileged($authorization->getRoles())) {
$transformedAt = $file->getAttribute('transformedAt', '');
@@ -677,16 +696,27 @@ Http::init()
}
}
$accessedAt = $cacheLog->getAttribute('accessedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([
'accessedAt' => DateTime::now(),
])));
// Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid
$cache->save($key, $data);
}
$response
->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp))
->addHeader('X-Appwrite-Cache', 'hit')
->setContentType($cacheLog->getAttribute('mimeType'));
$storageCacheOperationsCounter->add(1, ['result' => 'hit']);
if (! $isImageTransformation || ! $isDisabled) {
Span::add('storage.cache.hit', true);
$response->send($data);
}
} else {
$storageCacheOperationsCounter->add(1, ['result' => 'miss']);
Span::add('storage.cache.hit', false);
$response
->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
->addHeader('Pragma', 'no-cache')
@@ -724,7 +754,12 @@ Http::shutdown()
->inject('project')
->inject('dbForProject')
->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) {
$sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT;
$sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0;
if ($sessionLimit === 0) {
return;
}
$session = $response->getPayload();
$userId = $session['userId'] ?? '';
if (empty($userId)) {
@@ -758,7 +793,8 @@ Http::shutdown()
->inject('project')
->inject('user')
->inject('queueForEvents')
->inject('queueForAudits')
->inject('auditContext')
->inject('publisherForAudits')
->inject('usage')
->inject('publisherForUsage')
->inject('queueForDeletes')
@@ -774,7 +810,8 @@ Http::shutdown()
->inject('eventProcessor')
->inject('bus')
->inject('apiKey')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey) use ($parseLabel) {
->inject('mode')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -867,18 +904,20 @@ Http::shutdown()
*/
$pattern = $route->getLabel('audits.resource', null);
if (! empty($pattern)) {
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
if (! empty($resource) && $resource !== $pattern) {
$queueForAudits->setResource($resource);
$auditContext->resource = $resource;
}
}
if (! $user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
$queueForAudits->setUser($userClone);
} elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) {
if (empty($user->getAttribute('type'))) {
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
}
$auditContext->user = $userClone;
} elseif ($auditContext->user === null || $auditContext->user->isEmpty()) {
/**
* User in the request is empty, and no user was set for auditing previously.
* This indicates:
@@ -896,24 +935,21 @@ Http::shutdown()
'name' => 'Guest',
]);
$queueForAudits->setUser($user);
$auditContext->user = $user;
}
if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) {
$auditUser = $auditContext->user;
if (! empty($auditContext->resource) && ! $auditUser->isEmpty()) {
/**
* audits.payload is switched to default true
* in order to auto audit payload for all endpoints
*/
$pattern = $route->getLabel('audits.payload', true);
if (! empty($pattern)) {
$queueForAudits->setPayload($responsePayload);
$auditContext->payload = $responsePayload;
}
foreach ($queueForEvents->getParams() as $key => $value) {
$queueForAudits->setParam($key, $value);
}
$queueForAudits->trigger();
$publisherForAudits->enqueue(AuditMessage::fromContext($auditContext));
}
if (! empty($queueForDeletes->getType())) {
@@ -937,15 +973,16 @@ Http::shutdown()
if ($useCache) {
$resource = $resourceType = null;
$data = $response->getPayload();
if (! empty($data['payload'])) {
$statusCode = $response->getStatusCode();
if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) {
$pattern = $route->getLabel('cache.resource', null);
if (! empty($pattern)) {
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$pattern = $route->getLabel('cache.resourceType', null);
if (! empty($pattern)) {
$resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user);
$resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$cache = new Cache(
+62 -68
View File
@@ -1,16 +1,13 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
global $register;
$registerRequestResources = require __DIR__ . '/init/resources/request.php';
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Swoole\Constant;
use Swoole\Http\Request as SwooleRequest;
use Swoole\Http\Response as SwooleResponse;
use Swoole\Http\Server;
use Swoole\Process;
use Swoole\Table;
use Swoole\Timer;
@@ -29,6 +26,7 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Http\Adapter\Swoole\Server;
use Utopia\Http\Files;
use Utopia\Http\Http;
use Utopia\Logger\Log;
@@ -49,18 +47,33 @@ $certifiedDomains = new Table(100_000);
$certifiedDomains->column('value', Table::TYPE_INT, 1);
$certifiedDomains->create();
Http::setResource('riskyDomains', fn () => $riskyDomains);
Http::setResource('certifiedDomains', fn () => $certifiedDomains);
$http = new Server(
host: "0.0.0.0",
port: System::getEnv('PORT', 80),
mode: SWOOLE_PROCESS,
);
global $container;
$container->set('riskyDomains', fn () => $riskyDomains);
$container->set('certifiedDomains', fn () => $certifiedDomains);
$container->set('pools', function ($register) {
return $register->get('pools');
}, ['register']);
$payloadSize = 12 * (1024 * 1024); // 12MB - adding slight buffer for headers and other data that might be sent with the payload - update later with valid testing
$totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
$swooleAdapter = new Server(
host: "0.0.0.0",
port: System::getEnv('PORT', 80),
settings: [
Constant::OPTION_WORKER_NUM => $totalWorkers,
Constant::OPTION_DISPATCH_FUNC => dispatch(...),
Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD,
Constant::OPTION_HTTP_COMPRESSION => false,
Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize,
Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
],
container: $container,
);
$http = $swooleAdapter->getServer();
/**
* Assigns HTTP requests to worker threads by analyzing its payload/content.
*
@@ -69,16 +82,16 @@ $totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intva
* riskier tasks to a dedicated worker subset. Prefers idle workers, with fallback to random selection if necessary.
* doc: https://openswoole.com/docs/modules/swoole-server/configuration#dispatch_func
*
* @param Server $server Swoole server instance.
* @param \Swoole\Http\Server $server Swoole server instance.
* @param int $fd client ID
* @param int $type the type of data and its current state
* @param string|null $data Request content for categorization.
* @global int $totalThreads Total number of workers.
* @return int Chosen worker ID for the request.
*/
function dispatch(Server $server, int $fd, int $type, $data = null): int
function dispatch(\Swoole\Http\Server $server, int $fd, int $type, $data = null): int
{
$resolveWorkerId = function (Server $server, $data = null) {
$resolveWorkerId = function (\Swoole\Http\Server $server, $data = null) {
global $totalWorkers, $riskyDomains;
// If data is not set we can send request to any worker
@@ -161,18 +174,6 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int
return $workerId;
}
$http
->set([
Constant::OPTION_WORKER_NUM => $totalWorkers,
Constant::OPTION_DISPATCH_FUNC => dispatch(...),
Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD,
Constant::OPTION_HTTP_COMPRESSION => false,
Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize,
Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
]);
$http->on(Constant::EVENT_WORKER_START, function ($server, $workerId) {
});
@@ -189,16 +190,14 @@ $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']);
$container->set('bus', function ($register) use ($swooleAdapter) {
return $register->get('bus')->setResolver(fn (string $name) => $swooleAdapter->getContainer()->get($name));
}, ['register']);
include __DIR__ . '/controllers/general.php';
function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
{
$max = 15;
$sleep = 2;
$max = 15;
$sleep = 2;
$attempts = 0;
@@ -289,13 +288,13 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
Span::current()?->finish();
}
$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $totalWorkers, $register) {
$app = new Http('UTC');
$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $swooleAdapter) {
$app = new Http($swooleAdapter, 'UTC');
go(function () use ($register, $app) {
$pools = $register->get('pools');
/** @var \Utopia\Pools\Group $pools */
Http::setResource('pools', fn () => $pools);
/** @var \Utopia\Pools\Group $pools */
$pools = $app->getResource('pools');
go(function () use ($app, $pools) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
@@ -414,27 +413,19 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot
$projectCollections = $collections['projects'];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
$sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
$sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1);
$documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''));
$documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''));
$documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1);
$vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
$vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''));
$vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1);
$cache = $app->getResource('cache');
// All shared tables V2 pools that need project metadata collections
$sharedTablesV2All = \array_values(\array_unique(\array_filter([
...$sharedTablesV2,
...$documentsSharedTablesV2,
...$vectorSharedTablesV2,
// All shared tables pools that need project metadata collections
$allSharedTables = \array_values(\array_unique(\array_filter([
...$sharedTables,
...$documentsSharedTables,
...$vectorSharedTables,
])));
foreach ($sharedTablesV2All as $hostname) {
foreach ($allSharedTables as $hostname) {
Span::init('database.setup');
Span::add('database.hostname', $hostname);
@@ -511,14 +502,11 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot
});
});
$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) use ($register, $files) {
$swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swooleAdapter, $registerRequestResources) {
Span::init('http.request');
Http::setResource('swooleRequest', fn () => $swooleRequest);
Http::setResource('swooleResponse', fn () => $swooleResponse);
$request = new Request($swooleRequest);
$response = new Response($swooleResponse);
$request = new Request($utopiaRequest->getSwooleRequest());
$response = new Response($utopiaResponse->getSwooleResponse());
Span::add('http.method', $request->getMethod());
@@ -534,13 +522,19 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
return;
}
$app = new Http('UTC');
$requestContainer = $swooleAdapter->getContainer();
$requestContainer->set('container', fn () => $requestContainer);
$requestContainer->set('request', fn () => $request);
$requestContainer->set('response', fn () => $response);
$app = new Http($swooleAdapter, 'UTC');
$requestContainer->set('utopia', fn () => $app);
$registerRequestResources($requestContainer);
$app->setCompression(System::getEnv('_APP_COMPRESSION_ENABLED', 'enabled') === 'enabled');
$app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB
$pools = $register->get('pools');
Http::setResource('pools', fn () => $pools);
try {
$authorization = $app->getResource('authorization');
@@ -624,6 +618,7 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
}
}
$swooleResponse = $utopiaResponse->getSwooleResponse();
$swooleResponse->setStatusCode(500);
$output = ((Http::isDevelopment())) ? [
@@ -647,11 +642,10 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
});
// Fetch domains every `DOMAIN_SYNC_TIMER` seconds and update in the memory
$http->on(Constant::EVENT_TASK, function () use ($register) {
$http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
$lastSyncUpdate = null;
$pools = $register->get('pools');
Http::setResource('pools', fn () => $pools);
$app = new Http('UTC');
$app = new Http($swooleAdapter, 'UTC');
/** @var Utopia\Database\Database $dbForPlatform */
$dbForPlatform = $app->getResource('dbForPlatform');
@@ -726,4 +720,4 @@ $http->on(Constant::EVENT_TASK, function () use ($register) {
});
});
$http->start();
$swooleAdapter->start();
+1 -1
View File
@@ -12,7 +12,7 @@ Config::load('runtimes-v2', __DIR__ . '/../config/runtimes-v2.php', $configAdapt
Config::load('template-runtimes', __DIR__ . '/../config/template-runtimes.php', $configAdapter);
Config::load('events', __DIR__ . '/../config/events.php', $configAdapter);
Config::load('auth', __DIR__ . '/../config/auth.php', $configAdapter);
Config::load('apis', __DIR__ . '/../config/apis.php', $configAdapter); // List of APIs
Config::load('protocols', __DIR__ . '/../config/protocols.php', $configAdapter);
Config::load('errors', __DIR__ . '/../config/errors.php', $configAdapter);
Config::load('oAuthProviders', __DIR__ . '/../config/oAuthProviders.php', $configAdapter);
Config::load('sdks', __DIR__ . '/../config/sdks.php', $configAdapter);
+8 -7
View File
@@ -1,6 +1,7 @@
<?php
use Appwrite\Platform\Modules\Compute\Specification;
use Utopia\System\System;
const APP_NAME = 'Appwrite';
const APP_DOMAIN = 'appwrite.io';
@@ -24,9 +25,6 @@ const APP_MODE_ADMIN = 'admin';
const APP_PAGING_LIMIT = 12;
const APP_LIMIT_COUNT = 5000;
const APP_LIMIT_USERS = 10_000;
const APP_LIMIT_USER_PASSWORD_HISTORY = 20;
const APP_LIMIT_USER_SESSIONS_MAX = 100;
const APP_LIMIT_USER_SESSIONS_DEFAULT = 10;
const APP_LIMIT_ANTIVIRUS = 20_000_000; //20MB
const APP_LIMIT_ENCRYPTION = 20_000_000; //20MB
const APP_LIMIT_COMPRESSION = 20_000_000; //20MB
@@ -46,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours
const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
const APP_CACHE_BUSTER = 4321;
const APP_VERSION_STABLE = '1.9.0';
const APP_CACHE_BUSTER = 4323;
const APP_VERSION_STABLE = '1.9.2';
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
@@ -156,9 +154,12 @@ const SESSION_PROVIDER_SERVER = 'server';
/**
* Activity associated with user or the app.
*/
const ACTIVITY_TYPE_APP = 'app';
const ACTIVITY_TYPE_USER = 'user';
const ACTIVITY_TYPE_ADMIN = 'admin';
const ACTIVITY_TYPE_GUEST = 'guest';
const ACTIVITY_TYPE_KEY_PROJECT = 'keyProject';
const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount';
const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization';
/**
* MFA
@@ -187,7 +188,7 @@ const BUILD_TYPE_RETRY = 'retry';
// Deletion Types
const ENABLE_EXECUTIONS_LIMIT_ON_ROUTE = false;
\define('ENABLE_EXECUTIONS_LIMIT_ON_ROUTE', System::getEnv('_APP_EXECUTIONS_LIMIT_ON_ROUTE', 'disabled') === 'enabled');
const DELETE_TYPE_DATABASES = 'databases';
const DELETE_TYPE_DOCUMENT = 'document';
+8 -1
View File
@@ -1,5 +1,6 @@
<?php
use Appwrite\Network\Platform;
use Appwrite\OpenSSL\OpenSSL;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -123,11 +124,17 @@ Database::addFilter(
return;
},
function (mixed $value, Document $document, Database $database) {
return $database->getAuthorization()->skip(fn () => $database
$platforms = $database->getAuthorization()->skip(fn () => $database
->find('platforms', [
Query::equal('projectInternalId', [$document->getSequence()]),
Query::limit(APP_LIMIT_SUBQUERY),
]));
foreach ($platforms as $platform) {
$platform->setAttribute('type', Platform::mapDeprecatedType($platform->getAttribute('type')));
}
return $platforms;
}
);
+38 -7
View File
@@ -106,13 +106,30 @@ use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\Platform;
use Appwrite\Utopia\Response\Model\PlatformAndroid;
use Appwrite\Utopia\Response\Model\PlatformApple;
use Appwrite\Utopia\Response\Model\PlatformLinux;
use Appwrite\Utopia\Response\Model\PlatformList;
use Appwrite\Utopia\Response\Model\PlatformWeb;
use Appwrite\Utopia\Response\Model\PlatformWindows;
use Appwrite\Utopia\Response\Model\PolicyList;
use Appwrite\Utopia\Response\Model\PolicyMembershipPrivacy;
use Appwrite\Utopia\Response\Model\PolicyPasswordDictionary;
use Appwrite\Utopia\Response\Model\PolicyPasswordHistory;
use Appwrite\Utopia\Response\Model\PolicyPasswordPersonalData;
use Appwrite\Utopia\Response\Model\PolicySessionAlert;
use Appwrite\Utopia\Response\Model\PolicySessionDuration;
use Appwrite\Utopia\Response\Model\PolicySessionInvalidation;
use Appwrite\Utopia\Response\Model\PolicySessionLimit;
use Appwrite\Utopia\Response\Model\PolicyUserLimit;
use Appwrite\Utopia\Response\Model\Preferences;
use Appwrite\Utopia\Response\Model\Project;
use Appwrite\Utopia\Response\Model\Provider;
use Appwrite\Utopia\Response\Model\ProviderRepository;
use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework;
use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList;
use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime;
use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList;
use Appwrite\Utopia\Response\Model\ResourceToken;
use Appwrite\Utopia\Response\Model\Row;
use Appwrite\Utopia\Response\Model\Rule;
@@ -130,7 +147,6 @@ use Appwrite\Utopia\Response\Model\TemplateFramework;
use Appwrite\Utopia\Response\Model\TemplateFunction;
use Appwrite\Utopia\Response\Model\TemplateRuntime;
use Appwrite\Utopia\Response\Model\TemplateSite;
use Appwrite\Utopia\Response\Model\TemplateSMS;
use Appwrite\Utopia\Response\Model\TemplateVariable;
use Appwrite\Utopia\Response\Model\Token;
use Appwrite\Utopia\Response\Model\Topic;
@@ -185,8 +201,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_
Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION));
Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION));
Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION));
Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK));
Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME));
Response::setModel(new ProviderRepositoryFrameworkList());
Response::setModel(new ProviderRepositoryRuntimeList());
Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH));
Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK));
Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME));
@@ -197,7 +213,6 @@ Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, '
Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true));
Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false));
Response::setModel(new BaseList('Auth Providers List', Response::MODEL_AUTH_PROVIDER_LIST, 'platforms', Response::MODEL_AUTH_PROVIDER, true, false));
Response::setModel(new BaseList('Platforms List', Response::MODEL_PLATFORM_LIST, 'platforms', Response::MODEL_PLATFORM, true, false));
Response::setModel(new BaseList('Countries List', Response::MODEL_COUNTRY_LIST, 'countries', Response::MODEL_COUNTRY));
Response::setModel(new BaseList('Continents List', Response::MODEL_CONTINENT_LIST, 'continents', Response::MODEL_CONTINENT));
Response::setModel(new BaseList('Languages List', Response::MODEL_LANGUAGE_LIST, 'languages', Response::MODEL_LANGUAGE));
@@ -205,6 +220,9 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST
Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE));
Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false));
Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE));
Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER));
Response::setModel(new PolicyList());
Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE));
Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS));
Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE));
Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE));
@@ -332,8 +350,22 @@ Response::setModel(new Webhook());
Response::setModel(new Key());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
Response::setModel(new PolicyPasswordDictionary());
Response::setModel(new PolicyPasswordHistory());
Response::setModel(new PolicyPasswordPersonalData());
Response::setModel(new PolicySessionAlert());
Response::setModel(new PolicySessionDuration());
Response::setModel(new PolicySessionInvalidation());
Response::setModel(new PolicySessionLimit());
Response::setModel(new PolicyUserLimit());
Response::setModel(new PolicyMembershipPrivacy());
Response::setModel(new AuthProvider());
Response::setModel(new Platform());
Response::setModel(new PlatformWeb());
Response::setModel(new PlatformApple());
Response::setModel(new PlatformAndroid());
Response::setModel(new PlatformWindows());
Response::setModel(new PlatformLinux());
Response::setModel(new PlatformList());
Response::setModel(new Variable());
Response::setModel(new Country());
Response::setModel(new Continent());
@@ -364,7 +396,6 @@ Response::setModel(new Headers());
Response::setModel(new Specification());
Response::setModel(new Rule());
Response::setModel(new Schedule());
Response::setModel(new TemplateSMS());
Response::setModel(new TemplateEmail());
Response::setModel(new ConsoleVariables());
Response::setModel(new MFAChallenge());
+366
View File
@@ -0,0 +1,366 @@
<?php
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Extend\Exception;
use Appwrite\Network\Platform;
use Appwrite\Network\Validator\Origin;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Utopia\Auth\Hashes\Sha;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Database\DateTime as DatabaseDateTime;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\System\System;
use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
/**
* Register the minimal per-connection resources required by realtime.
*/
return function (Container $container): void {
$getProjectId = static function (Request $request): string {
$projectId = $request->getHeader('x-appwrite-project', '');
if (!empty($projectId)) {
return $projectId;
}
$projectId = $request->getParam('project', '');
return \is_string($projectId) ? $projectId : '';
};
$getMode = static function (Request $request, Document $project) use ($getProjectId): string {
$mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT));
$projectId = $getProjectId($request);
if (!empty($projectId) && $project->getId() !== $projectId) {
$mode = APP_MODE_ADMIN;
}
return $mode;
};
$getDbForPlatform = static function (Authorization $authorization) {
$database = getConsoleDB();
$database->setAuthorization($authorization);
return $database;
};
$getDbForProject = static function (Document $project, Authorization $authorization) use ($getDbForPlatform) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $getDbForPlatform($authorization);
}
$database = getProjectDB($project);
$database->setAuthorization($authorization);
return $database;
};
$findRule = static function (Request $request, Document $project, Authorization $authorization) use ($getDbForPlatform): Document {
$domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
if (empty($domain)) {
$domain = \parse_url($request->getReferer(), PHP_URL_HOST);
}
if (empty($domain)) {
return new Document();
}
$dbForPlatform = $getDbForPlatform($authorization);
$isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
$rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) {
if ($isMd5) {
return $dbForPlatform->getDocument('rules', md5($domain));
}
return $dbForPlatform->findOne('rules', [
Query::equal('domain', [$domain]),
]);
});
$permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) {
$trustedProjects = [];
foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
if (empty($trustedProject)) {
continue;
}
$trustedProjects[] = $trustedProject;
}
if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects, true)) {
$permitsCurrentProject = true;
}
}
if (!$permitsCurrentProject) {
return new Document();
}
return $rule;
};
$findDevKey = static function (Request $request, Document $project, array $servers, Authorization $authorization) use ($getDbForPlatform): Document {
$devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
$key = $project->find('secret', $devKey, 'devKeys');
if (!$key) {
return new Document([]);
}
$expire = $key->getAttribute('expire');
if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
return new Document([]);
}
$dbForPlatform = $getDbForPlatform($authorization);
$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(), new Document([
'accessedAt' => $key->getAttribute('accessedAt'),
])));
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
}
$sdkValidator = new WhiteList($servers, true);
$sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) {
$sdks = $key->getAttribute('sdks', []);
if (!\in_array($sdk, $sdks, true)) {
$sdks[] = $sdk;
$key->setAttribute('sdks', $sdks);
$key->setAttribute('accessedAt', DatabaseDateTime::now());
$key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
'sdks' => $key->getAttribute('sdks'),
'accessedAt' => $key->getAttribute('accessedAt'),
])));
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
}
}
return $key;
};
$container->set('authorization', function () {
return new Authorization();
}, []);
$container->set('project', function (Request $request, Document $console, Authorization $authorization) use ($getProjectId, $getDbForPlatform) {
$projectId = $getProjectId($request);
if (empty($projectId) || $projectId === 'console') {
return $console;
}
$dbForPlatform = $getDbForPlatform($authorization);
return $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
}, ['request', 'console', 'authorization']);
$container->set('originValidator', function (array $platform, Request $request, Document $project, array $servers, Authorization $authorization) use ($findDevKey, $findRule) {
$devKey = $findDevKey($request, $project, $servers, $authorization);
if (!$devKey->isEmpty()) {
return new URL();
}
$allowedHostnames = [...($platform['hostnames'] ?? [])];
if (!$project->isEmpty() && $project->getId() !== 'console') {
$allowedHostnames = [...$allowedHostnames, ...Platform::getHostnames($project->getAttribute('platforms', []))];
}
$rule = $findRule($request, $project, $authorization);
if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) {
$allowedHostnames[] = $rule->getAttribute('domain', '');
}
$originHostname = \parse_url($request->getOrigin(), PHP_URL_HOST);
$refererHostname = \parse_url($request->getReferer(), PHP_URL_HOST);
$hostname = $originHostname ?: $refererHostname;
if ($request->getMethod() === 'OPTIONS' && !empty($hostname)) {
$allowedHostnames[] = $hostname;
}
$allowedSchemes = [...($platform['schemas'] ?? [])];
if (!$project->isEmpty() && $project->getId() !== 'console') {
$allowedSchemes[] = 'exp';
$allowedSchemes[] = 'appwrite-callback-' . $project->getId();
$allowedSchemes = [...$allowedSchemes, ...Platform::getSchemes($project->getAttribute('platforms', []))];
}
return new Origin(\array_unique($allowedHostnames), \array_unique($allowedSchemes));
}, ['platform', 'request', 'project', 'servers', 'authorization']);
$container->set('user', function (Request $request, Document $project, Document $console, Authorization $authorization) use ($getMode, $getDbForPlatform, $getDbForProject) {
$mode = $getMode($request, $project);
$store = new Store();
$proofForToken = new Token();
$proofForToken->setHash(new Sha());
$authorization->setDefaultStatus(true);
$dbForPlatform = $getDbForPlatform($authorization);
$dbForProject = $getDbForProject($project, $authorization);
$store->setKey('a_session_' . $project->getId());
if ($mode === APP_MODE_ADMIN) {
$store->setKey('a_session_' . $console->getId());
}
$store->decode(
$request->getCookie(
$store->getKey(),
$request->getCookie($store->getKey() . '_legacy', '')
)
);
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
$sessionHeader = $request->getHeader('x-appwrite-session', '');
if (!empty($sessionHeader)) {
$store->decode($sessionHeader);
}
}
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
$fallback = \json_decode($request->getHeader('x-fallback-cookies', ''), true);
$store->decode((\is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '');
}
$user = null;
if ($mode === APP_MODE_ADMIN) {
/** @var User $user */
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
} else {
if ($project->isEmpty()) {
$user = new User([]);
} elseif (!empty($store->getProperty('id', ''))) {
if ($project->getId() === 'console') {
/** @var User $user */
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
} else {
/** @var User $user */
$user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
}
}
}
if (
!$user
|| $user->isEmpty()
|| !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
) {
$user = new User([]);
}
$authJWT = $request->getHeader('x-appwrite-jwt', '');
if (!empty($authJWT) && !$project->isEmpty()) {
if (!$user->isEmpty()) {
throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
try {
$payload = $jwt->decode($authJWT);
} catch (JWTException $error) {
throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
}
$jwtUserId = $payload['userId'] ?? '';
if (!empty($jwtUserId)) {
if ($mode === APP_MODE_ADMIN) {
$user = $dbForPlatform->getDocument('users', $jwtUserId);
} else {
$user = $dbForProject->getDocument('users', $jwtUserId);
}
}
$jwtSessionId = $payload['sessionId'] ?? '';
if (!empty($jwtSessionId) && empty($user->find('$id', $jwtSessionId, 'sessions'))) {
$user = new User([]);
}
}
$accountKey = $request->getHeader('x-appwrite-key', '');
$accountKeyUserId = $request->getHeader('x-appwrite-user', '');
if (!empty($accountKeyUserId) && !empty($accountKey)) {
if (!$user->isEmpty()) {
throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
}
$accountKeyUser = $authorization->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
if (!$accountKeyUser->isEmpty()) {
$key = $accountKeyUser->find(
key: 'secret',
find: $accountKey,
subject: 'keys'
);
if (!empty($key)) {
$expire = $key->getAttribute('expire');
if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
}
$user = $accountKeyUser;
}
}
}
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
$userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
$targetUser = null;
if (!empty($impersonateUserId)) {
$targetUser = $authorization->skip(fn () => $userDb->getDocument('users', $impersonateUserId));
} elseif (!empty($impersonateEmail)) {
$targetUser = $authorization->skip(fn () => $userDb->findOne('users', [
Query::equal('email', [\strtolower($impersonateEmail)]),
]));
} elseif (!empty($impersonatePhone)) {
$targetUser = $authorization->skip(fn () => $userDb->findOne('users', [
Query::equal('phone', [$impersonatePhone]),
]));
}
if ($targetUser !== null && !$targetUser->isEmpty()) {
$impersonator = clone $user;
$user = clone $targetUser;
$user->setAttribute('impersonatorUserId', $impersonator->getId());
$user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence());
$user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', ''));
$user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', ''));
$user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0));
}
}
$dbForPlatform->setMetadata('user', $user->getId());
$dbForProject->setMetadata('user', $user->getId());
return $user;
}, ['request', 'project', 'console', 'authorization']);
};
+8 -20
View File
@@ -71,7 +71,7 @@ $register->set('logger', function () {
$providerConfig = match ($providerName) {
'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',],
'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''],
'logowl' => ['ticket' => $configChunks[0], 'host' => ''],
default => ['key' => $providerConfig],
};
}
@@ -245,26 +245,15 @@ $register->set('pools', function () {
$maxConnections = (int) System::getEnv('_APP_CONNECTIONS_MAX', 151);
$instanceConnections = $maxConnections / (int) System::getEnv('_APP_POOL_CLIENTS', 14);
$multiprocessing = System::getEnv('_APP_SERVER_MULTIPROCESS', 'disabled') === 'enabled';
if ($multiprocessing) {
$workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
} else {
$workerCount = 1;
}
if ($workerCount > $instanceConnections) {
throw new \Exception('Pool size is too small. Increase the number of allowed database connections or decrease the number of workers.', 500);
}
$poolSize = (int)($instanceConnections / $workerCount);
$workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
$poolSize = max(1, (int)($instanceConnections / $workerCount));
foreach ($connections as $key => $connection) {
$type = $connection['type'] ?? '';
$multiple = $connection['multiple'] ?? false;
$schemes = $connection['schemes'] ?? [];
$type = $connection['type'];
$multiple = $connection['multiple'];
$schemes = $connection['schemes'];
$config = [];
$dsns = explode(',', $connection['dsns'] ?? '');
$dsns = explode(',', $connection['dsns']);
foreach ($dsns as &$dsn) {
$dsn = explode('=', $dsn);
$name = ($multiple) ? $key . '_' . $dsn[0] : $key;
@@ -329,7 +318,7 @@ $register->set('pools', function () {
));
});
},
'redis' => function () use ($dsnHost, $dsnPort, $dsnPass) {
default => function () use ($dsnHost, $dsnPort, $dsnPass) {
$redis = new \Redis();
@$redis->pconnect($dsnHost, (int)$dsnPort);
if ($dsnPass) {
@@ -339,7 +328,6 @@ $register->set('pools', function () {
return $redis;
},
default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid scheme'),
};
$poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool();
+69 -1373
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+436
View File
@@ -0,0 +1,436 @@
<?php
use Appwrite\Event\Build;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Cache;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Publisher;
use Utopia\Registry\Registry;
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
/**
* Register per-job resources on the given container.
* These resources depend on the queue message or keep mutable state and
* must be fresh for each worker job.
*/
return function (Container $container): void {
$container->set('log', fn () => new Log(), []);
$container->set('usage', fn () => new Context(), []);
$container->set('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
return $authorization;
}, []);
$container->set('dbForPlatform', function (Cache $cache, Group $pools, Authorization $authorization) {
$adapter = new DatabasePool($pools->get('console'));
$dbForPlatform = new Database($adapter, $cache);
$dbForPlatform
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setNamespace('_console')
->setDocumentType('users', User::class);
return $dbForPlatform;
}, ['cache', 'pools', 'authorization']);
$container->set('project', function ($message, Database $dbForPlatform) {
$payload = $message->getPayload() ?? [];
$project = new Document($payload['project'] ?? []);
if ($project->isEmpty() || $project->getId() === 'console') {
return $project;
}
return $dbForPlatform->getDocument('projects', $project->getId());
}, ['message', 'dbForPlatform']);
$container->set('dbForProject', function (Cache $cache, Group $pools, Document $project, Database $dbForPlatform, Authorization $authorization) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
try {
$dsn = new DSN($project->getAttribute('database'));
} catch (\InvalidArgumentException) {
// TODO: Temporary until all projects are using shared tables
$dsn = new DSN('mysql://' . $project->getAttribute('database'));
}
$adapter = new DatabasePool($pools->get($dsn->getHost()));
$database = new Database($adapter, $cache);
$database->setDocumentType('users', User::class);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $project->getSequence());
}
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
return $database;
}, ['cache', 'pools', 'project', 'dbForPlatform', 'authorization']);
$container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
$databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases): Database {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
try {
$dsn = new DSN($project->getAttribute('database'));
} catch (\InvalidArgumentException) {
// TODO: Temporary until all projects are using shared tables
$dsn = new DSN('mysql://' . $project->getAttribute('database'));
}
if (isset($databases[$dsn->getHost()])) {
$database = $databases[$dsn->getHost()];
$database->setAuthorization($authorization);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $project->getSequence());
}
return $database;
}
$adapter = new DatabasePool($pools->get($dsn->getHost()));
$database = new Database($adapter, $cache);
$databases[$dsn->getHost()] = $database;
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $project->getSequence());
}
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
return $database;
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
$container->set('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) {
return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database {
$projectDocument ??= $project;
$databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
$databaseType = $database->getAttribute('type', '');
// Backwards-compatibility: older or seeded legacy databases may not have a DSN stored
// in the "database" attribute. In that case, fall back to the project's database DSN.
if ($databaseDSN === '') {
$databaseDSN = $projectDocument->getAttribute('database', '');
}
try {
$databaseDSN = new DSN($databaseDSN);
} catch (\InvalidArgumentException) {
$databaseDSN = new DSN('mysql://' . $databaseDSN);
}
try {
$dsn = new DSN($projectDocument->getAttribute('database'));
} catch (\InvalidArgumentException) {
// Temporary fallback until all projects use shared tables
$dsn = new DSN('mysql://' . $projectDocument->getAttribute('database'));
}
$pools = $register->get('pools');
$databaseHost = $databaseDSN->getHost();
$pool = $pools->get($databaseHost);
$adapter = new DatabasePool($pool);
$database = new Database($adapter, $cache);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization);
$database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
// For separate pools (documentsdb/vectorsdb), check their own shared tables config.
// If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
if ($databaseHost !== $dsn->getHost()) {
$dbTypeSharedTables = match ($databaseType) {
DOCUMENTSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))),
VECTORSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))),
default => [],
};
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
->setTenant($projectDocument->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $projectDocument->getSequence());
}
} elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
->setTenant($projectDocument->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $projectDocument->getSequence());
}
$database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
return $database;
};
}, ['cache', 'register', 'project', 'authorization']);
$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant($project->getSequence());
return $database;
}
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant($project->getSequence());
}
return $database;
};
}, ['pools', 'cache', 'authorization']);
$container->set('abuseRetention', function () {
return \time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day
}, []);
$container->set('auditRetention', function (Document $project) {
if ($project->getId() === 'console') {
return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months
}
return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days
}, ['project']);
$container->set('executionRetention', function () {
return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days
}, []);
$container->set('queueForDatabase', function (Publisher $publisher) {
return new EventDatabase($publisher);
}, ['publisher']);
$container->set('queueForMessaging', function (Publisher $publisher) {
return new Messaging($publisher);
}, ['publisher']);
$container->set('queueForMails', function (Publisher $publisher) {
return new Mail($publisher);
}, ['publisher']);
$container->set('queueForBuilds', function (Publisher $publisher) {
return new Build($publisher);
}, ['publisher']);
$container->set('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
$container->set('queueForEvents', function (Publisher $publisher) {
return new Event($publisher);
}, ['publisher']);
$container->set('queueForWebhooks', function (Publisher $publisher) {
return new Webhook($publisher);
}, ['publisher']);
$container->set('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
$container->set('queueForRealtime', function () {
return new Realtime();
}, []);
$container->set('deviceForSites', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForFunctions', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForFiles', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForBuilds', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForCache', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('logError', function (Registry $register, Document $project) {
return function (Throwable $error, string $namespace, string $action, ?array $extras = null) use ($register, $project) {
$logger = $register->get('logger');
if ($logger) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$log = new Log();
$log->setNamespace($namespace);
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', \get_class($error));
$log->addTag('projectId', $project->getId());
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
if ($error->getPrevious() !== null) {
if ($error->getPrevious()->getMessage() != $error->getMessage()) {
$log->addExtra('previousMessage', $error->getPrevious()->getMessage());
}
$log->addExtra('previousFile', $error->getPrevious()->getFile());
$log->addExtra('previousLine', $error->getPrevious()->getLine());
}
foreach (($extras ?? []) as $key => $value) {
$log->addExtra($key, $value);
}
$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::warning("Failed: {$error->getMessage()}");
Console::warning($error->getTraceAsString());
if ($error->getPrevious() !== null) {
if ($error->getPrevious()->getMessage() != $error->getMessage()) {
Console::warning("Previous Failed: {$error->getPrevious()->getMessage()}");
}
Console::warning("Previous File: {$error->getPrevious()->getFile()} Line: {$error->getPrevious()->getLine()}");
}
};
}, ['register', 'project']);
$container->set('getAudit', function (Database $dbForPlatform, callable $getProjectDB) {
return function (Document $project) use ($dbForPlatform, $getProjectDB) {
if ($project->isEmpty() || $project->getId() === 'console') {
$adapter = new AdapterDatabase($dbForPlatform);
return new UtopiaAudit($adapter);
}
$dbForProject = $getProjectDB($project);
$adapter = new AdapterDatabase($dbForProject);
return new UtopiaAudit($adapter);
};
}, ['dbForPlatform', 'getProjectDB']);
$container->set('executionsRetentionCount', function (Document $project, array $plan) {
if ($project->getId() === 'console' || empty($plan)) {
return 0;
}
return (int) ($plan['executionsRetentionCount'] ?? 100);
}, ['project', 'plan']);
};
+2
View File
@@ -1,9 +1,11 @@
<?php
use Appwrite\Bus\Listeners\Log;
use Appwrite\Bus\Listeners\Mails;
use Appwrite\Bus\Listeners\Usage;
return [
new Log(),
new Mails(),
new Usage(),
];
+430 -48
View File
@@ -33,20 +33,24 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Http\Http;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Registry\Registry;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
use Utopia\WebSocket\Adapter;
use Utopia\WebSocket\Server;
/**
* @var Registry $register
*/
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
/** @var Registry $register */
$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized');
$registerConnectionResources ??= require __DIR__ . '/init/realtime/connection.php';
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
@@ -240,6 +244,11 @@ if (!function_exists('triggerStats')) {
}
}
global $container;
$container->set('pools', function ($register) {
return $register->get('pools');
}, ['register']);
$realtime = getRealtime();
/**
@@ -255,7 +264,9 @@ $stats->create();
$containerId = uniqid();
$statsDocument = null;
$workerNumber = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0))
?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
$adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80));
$adapter
@@ -387,10 +398,27 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::success('Worker ' . $workerId . ' started successfully');
$telemetry = getTelemetry($workerId);
$realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000];
$workerTelemetryAttributes = ['workerId' => (string) $workerId];
$register->set('telemetry', fn () => $telemetry);
$register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes);
$register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers'));
$register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients'));
$register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions'));
$register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections'));
$register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created'));
$register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent'));
$register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram(
name: 'realtime.server.delivery_delay',
unit: 'ms',
advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets],
));
$register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram(
name: 'realtime.server.arrival_delay',
unit: 'ms',
advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets],
));
$register->get('telemetry.workerCounter')->add(1);
$attempts = 0;
$start = time();
@@ -441,7 +469,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
]
];
$server->send($realtime->getSubscribers($event), json_encode([
$server->send(array_keys($realtime->getSubscribers($event)), json_encode([
'type' => 'event',
'data' => $event['data']
]));
@@ -507,12 +535,28 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) {
$event = json_decode($payload, true);
$eventTimestamp = $event['data']['timestamp'] ?? null;
if (\is_string($eventTimestamp)) {
try {
$eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC'));
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$eventTimestampMs = (float) $eventDate->format('U.u') * 1000;
$nowTimestampMs = (float) $now->format('U.u') * 1000;
$arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs);
$register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs);
} catch (\Throwable) {
// Ignore invalid timestamp payloads.
}
}
if ($event['permissionsChanged'] && isset($event['userId'])) {
$projectId = $event['project'];
$userId = $event['userId'];
if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) {
$connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId]));
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$consoleDatabase = getConsoleDB();
$project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId));
$database = getProjectDB($project);
@@ -543,12 +587,18 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
if ($authorization !== null) {
$realtime->connections[$connection]['authorization'] = $authorization;
}
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
}
}
$receivers = $realtime->getSubscribers($event);
if (Http::isDevelopment() && !empty($receivers)) {
if (System::getEnv('_APP_ENV', 'production') === 'development' && !empty($receivers)) {
Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers));
Console::log("[Debug][Worker {$workerId}] Connection IDs: " . json_encode(array_keys($receivers)));
Console::log("[Debug][Worker {$workerId}] Matched: " . json_encode(array_values($receivers)));
@@ -585,6 +635,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
if ($total > 0) {
$register->get('telemetry.messageSentCounter')->add($total);
$stats->incr($event['project'], 'messages', $total);
$updatedAt = $event['data']['payload']['$updatedAt'] ?? null;
if (\is_string($updatedAt)) {
try {
$updatedAtDate = new \DateTimeImmutable($updatedAt, new \DateTimeZone('UTC'));
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000;
$nowTimestampMs = (float) $now->format('U.u') * 1000;
$delayMs = (int) \max(0, $nowTimestampMs - $updatedAtTimestampMs);
$register->get('telemetry.deliveryDelayHistogram')->record($delayMs);
} catch (\Throwable) {
// Ignore invalid timestamp payloads.
}
}
$projectId = $event['project'] ?? null;
@@ -614,25 +678,50 @@ $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) {
$app = new Http('UTC');
$server->onWorkerStop(function (int $workerId) use ($register) {
Console::warning('Worker ' . $workerId . ' stopping');
try {
$register->get('telemetry.workerCounter')->add(-1);
} catch (\Throwable $th) {
Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage());
}
});
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) {
global $container;
$request = new Request($request);
$response = new Response(new SwooleResponse());
Console::info("Connection open (user: {$connection})");
Http::setResource('pools', fn () => $register->get('pools'));
Http::setResource('request', fn () => $request);
Http::setResource('response', fn () => $response);
$connectionContainer = new Container($container);
$connectionContainer->set('request', fn () => $request);
$registerConnectionResources($connectionContainer);
$project = null;
$logUser = null;
$authorization = null;
$rawSize = $request->getSize();
$channelCount = 0;
$subscriptionCount = 0;
$outboundBytes = 0;
$responseCode = 200;
$subscriptionMode = 'message';
$success = false;
Span::init('realtime.open');
Span::add('realtime.connectionId', $connection);
Span::add('realtime.inboundBytes', $rawSize);
if (!empty($request->getOrigin())) {
Span::add('realtime.origin', $request->getOrigin());
}
try {
/** @var Document $project */
$project = $app->getResource('project');
$authorization = $app->getResource('authorization');
$project = $connectionContainer->get('project');
$authorization = $connectionContainer->get('authorization');
/*
* Project Check
@@ -641,13 +730,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing or unknown project ID');
}
$timelimit = $app->getResource('timelimit');
$user = $app->getResource('user'); /** @var User $user */
$timelimit = $connectionContainer->get('timelimit');
$user = $connectionContainer->get('user'); /** @var User $user */
$logUser = $user;
$apis = $project->getAttribute('apis', []);
// Websocket is what to check, but realtime is checked too for backwards compatibility
$websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true;
if (
array_key_exists('realtime', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['realtime']
!$websocketEnabled
&& !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
@@ -675,8 +766,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests');
}
$rawSize = $request->getSize();
triggerStats([
METRIC_REALTIME_INBOUND => $rawSize,
], $project->getId());
@@ -687,7 +776,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
* Skip this check for non-web platforms which are not required to send an origin header.
*/
$origin = $request->getOrigin();
$originValidator = $app->getResource('originValidator');
$originValidator = $connectionContainer->get('originValidator');
if (!empty($origin) && !$originValidator->isValid($origin) && $project->getId() !== 'console') {
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $originValidator->getDescription());
@@ -696,15 +785,53 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$roles = $user->getRoles($authorization);
$channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId());
$channelCount = \count($channels);
$updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void {
$register->get('telemetry.connectionCounter')->add(1);
$register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes'));
$register->get('telemetry.connectionCreatedCounter')->add(1);
$stats->set($projectId, [
'projectId' => $projectId,
'teamId' => $teamId
]);
$stats->incr($projectId, 'connections');
$stats->incr($projectId, 'connectionsTotal');
triggerStats([
METRIC_REALTIME_CONNECTIONS => 1,
METRIC_REALTIME_OUTBOUND => \strlen($payloadJson),
], $projectId);
};
/**
* Channels Check
*/
if (empty($channels)) {
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing channels');
// in case of message based 'subscribe' channels will be empty at first and only projectId and roles will be available
$sanitizedUser = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT);
$connectedPayloadJson = json_encode([
'type' => 'connected',
'data' => [
'channels' => [],
'subscriptions' => [],
'user' => $sanitizedUser
]
]);
$realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId());
$realtime->connections[$connection]['authorization'] = $authorization;
$server->send([$connection], $connectedPayloadJson);
$outboundBytes += \strlen($connectedPayloadJson);
$updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
$subscriptionMode = 'message';
$success = true;
return;
}
$names = array_keys($channels);
$subscriptionMode = 'url';
try {
$subscriptions = Realtime::constructSubscriptions(
@@ -725,11 +852,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$subscriptionId,
$roles,
$subscription['channels'],
$subscription['queries']
$subscription['queries'],
$user->getId()
);
$mapping[$index] = $subscriptionId;
}
$subscriptionCount = \count($subscriptions);
if (!empty($subscriptions)) {
$register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes'));
}
$realtime->connections[$connection]['authorization'] = $authorization;
@@ -745,21 +877,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
]);
$server->send([$connection], $connectedPayloadJson);
$register->get('telemetry.connectionCounter')->add(1);
$register->get('telemetry.connectionCreatedCounter')->add(1);
$stats->set($project->getId(), [
'projectId' => $project->getId(),
'teamId' => $project->getAttribute('teamId')
]);
$stats->incr($project->getId(), 'connections');
$stats->incr($project->getId(), 'connectionsTotal');
$connectedOutboundBytes = \strlen($connectedPayloadJson);
triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId());
$outboundBytes += \strlen($connectedPayloadJson);
$updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
$success = true;
} catch (Throwable $th) {
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
@@ -769,12 +889,13 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
if (!\is_int($code)) {
$code = 500;
}
$responseCode = $code;
$message = $th->getMessage();
// sanitize 0 && 5xx errors
$realtimeViolation = $th instanceof AppwriteException && $th->getType() === AppwriteException::REALTIME_POLICY_VIOLATION;
if (($code === 0 || $code >= 500) && !$realtimeViolation && !Http::isDevelopment()) {
if (($code === 0 || $code >= 500) && !$realtimeViolation && System::getEnv('_APP_ENV', 'production') !== 'development') {
$message = 'Error: Server Error';
}
@@ -786,25 +907,54 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
]
];
$server->send([$connection], json_encode($response));
$responsePayloadJson = json_encode($response);
$server->send([$connection], $responsePayloadJson);
$outboundBytes += \strlen($responsePayloadJson);
$server->close($connection, $code);
if (Http::isDevelopment()) {
if (System::getEnv('_APP_ENV', 'production') === 'development') {
Console::error('[Error] Connection Error');
Console::error('[Error] Code: ' . $response['data']['code']);
Console::error('[Error] Message: ' . $response['data']['message']);
}
Span::error($th);
} finally {
Span::add('realtime.success', $success);
Span::add('realtime.responseCode', $responseCode);
Span::add('realtime.subscriptionMode', $subscriptionMode);
Span::add('realtime.channelCount', $channelCount);
Span::add('realtime.subscriptionCount', $subscriptionCount);
Span::add('realtime.outboundBytes', $outboundBytes);
if (!empty($project?->getId())) {
Span::add('realtime.projectId', $project->getId());
}
if (!empty($logUser?->getId())) {
Span::add('realtime.userId', $logUser->getId());
}
Span::current()?->finish();
}
});
$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) {
$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) {
$project = null;
$authorization = null;
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
$rawSize = \strlen($message);
$messageType = 'invalid';
$subscriptionDelta = 0;
$subscriptionsRequested = 0;
$subscriptionsRemoved = 0;
$outboundBytes = 0;
$responseCode = 200;
$success = false;
Span::init('realtime.message');
Span::add('realtime.connectionId', $connection);
Span::add('realtime.inboundBytes', $rawSize);
Span::add('realtime.containerId', $containerId);
try {
$rawSize = \strlen($message);
$response = new Response(new SwooleResponse());
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
// Get authorization from connection (stored during onOpen)
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
@@ -854,6 +1004,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.');
}
$messageType = $message['type'] ?? 'invalid';
if (!\is_scalar($messageType)) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
// Ping does not require project context; other messages do (e.g. after unsubscribe during auth)
if (empty($projectId) && ($message['type'] ?? '') !== 'ping') {
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.');
@@ -866,6 +1022,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]);
$server->send([$connection], $pongPayloadJson);
$outboundBytes += \strlen($pongPayloadJson);
if ($project !== null && !$project->isEmpty()) {
$pongOutboundBytes = \strlen($pongPayloadJson);
@@ -912,6 +1069,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$meta = $realtime->getSubscriptionMetadata($connection);
$realtime->unsubscribe($connection);
@@ -926,7 +1084,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$subscriptionId,
$roles,
$subscription['channels'] ?? [],
$queries
$queries,
$user->getId()
);
}
}
@@ -935,6 +1094,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$realtime->connections[$connection]['authorization'] = $authorization;
}
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
$user = $response->output($user, Response::MODEL_ACCOUNT);
$authResponsePayloadJson = json_encode([
@@ -947,6 +1112,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]);
$server->send([$connection], $authResponsePayloadJson);
$outboundBytes += \strlen($authResponsePayloadJson);
if ($project !== null && !$project->isEmpty()) {
$authOutboundBytes = \strlen($authResponsePayloadJson);
@@ -960,20 +1126,185 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
break;
case 'subscribe':
/**
* Message based upsertion of a subscription
* If subscriptionId is given then it will match subId of the connection and update the subscription with channels and queries
* If non-existing subid is given or not given a new subid will be generated
* Similar to what we have now -> two subscribe() block with same channels and queries still two different subscriptions
*
* structure of the payload -> array of maps
* 'data' : [subscriptionId:"" , channels:[] , queries:[]]
*/
if (!is_array($message['data']) || !array_is_list($message['data'])) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
}
$roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()];
$userId = $realtime->connections[$connection]['userId'] ?? '';
// bulk validation + parsing before subscribing
$parsedPayloads = [];
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
foreach ($message['data'] as $payload) {
if (!\is_array($payload)) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.');
}
if (!array_key_exists('channels', $payload)) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.');
}
if (!is_array($payload['channels']) || !array_is_list($payload['channels'])) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.');
}
// registering the queries if not present and check in the same payload later on
if (!array_key_exists('queries', $payload)) {
$payload['queries'] = [];
}
if (!is_array($payload['queries']) || !array_is_list($payload['queries'])) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not a valid array.');
}
$subscriptionId = \array_key_exists('subscriptionId', $payload)
? $payload['subscriptionId']
: ID::unique();
try {
$convertedQueries = Realtime::convertQueries($payload['queries']);
} catch (QueryException $e) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage());
}
$convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId));
$parsedPayloads[] = [
'subscriptionId' => $subscriptionId,
'channels' => $payload['channels'],
'convertedChannels' => $convertedChannels,
'queries' => $convertedQueries,
];
}
foreach ($parsedPayloads as $parsedPayload) {
$subscriptionId = $parsedPayload['subscriptionId'];
$channels = $parsedPayload['convertedChannels'];
$queries = $parsedPayload['queries'];
$realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries);
}
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
$subscriptionsRequested = \count($parsedPayloads);
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
$responsePayload = json_encode([
'type' => 'response',
'data' => [
'to' => 'subscribe',
'success' => true,
'subscriptions' => \array_map(function (array $parsedPayload) {
return [
'subscriptionId' => $parsedPayload['subscriptionId'],
'channels' => $parsedPayload['convertedChannels'],
'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']),
];
}, $parsedPayloads),
]
]);
$server->send([$connection], $responsePayload);
$outboundBytes += \strlen($responsePayload);
if ($project !== null && !$project->isEmpty()) {
$subscribeOutboundBytes = \strlen($responsePayload);
if ($subscribeOutboundBytes > 0) {
triggerStats([
METRIC_REALTIME_OUTBOUND => $subscribeOutboundBytes,
], $project->getId());
}
}
break;
case 'unsubscribe':
if (!\is_array($message['data']) || !\array_is_list($message['data'])) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
}
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
// Validate every payload before executing any removal so an invalid entry
// later in the batch does not leave earlier entries half-applied on the server.
$validatedIds = [];
foreach ($message['data'] as $payload) {
if (
!\is_array($payload)
|| !\array_key_exists('subscriptionId', $payload)
|| !\is_string($payload['subscriptionId'])
|| $payload['subscriptionId'] === ''
) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.');
}
$validatedIds[] = $payload['subscriptionId'];
}
$unsubscribeResults = [];
foreach ($validatedIds as $subscriptionId) {
$wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId);
$unsubscribeResults[] = [
'subscriptionId' => $subscriptionId,
'removed' => $wasRemoved,
];
}
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
$subscriptionsRequested = \count($validatedIds);
$subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed']));
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
$unsubscribeResponsePayload = json_encode([
'type' => 'response',
'data' => [
'to' => 'unsubscribe',
'success' => true,
'subscriptions' => $unsubscribeResults,
],
]);
$server->send([$connection], $unsubscribeResponsePayload);
$outboundBytes += \strlen($unsubscribeResponsePayload);
if ($project !== null && !$project->isEmpty()) {
$unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload);
if ($unsubscribeOutboundBytes > 0) {
triggerStats([
METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes,
], $project->getId());
}
}
break;
default:
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
$success = true;
} catch (Throwable $th) {
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$code = $th->getCode();
if (!is_int($code)) {
$code = 500;
}
$responseCode = $code;
$message = $th->getMessage();
// sanitize 0 && 5xx errors
if (($code === 0 || $code >= 500) && !Http::isDevelopment()) {
if (($code === 0 || $code >= 500) && System::getEnv('_APP_ENV', 'production') !== 'development') {
$message = 'Error: Server Error';
}
@@ -985,19 +1316,52 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]
];
$server->send([$connection], json_encode($response));
$responsePayloadJson = json_encode($response);
$server->send([$connection], $responsePayloadJson);
$outboundBytes += \strlen($responsePayloadJson);
if ($th->getCode() === 1008) {
$server->close($connection, $th->getCode());
}
Span::error($th);
} finally {
Span::add('realtime.success', $success);
Span::add('realtime.responseCode', $responseCode);
Span::add('realtime.subscriptionDelta', $subscriptionDelta);
Span::add('realtime.subscriptionsRequested', $subscriptionsRequested);
Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved);
Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested);
Span::add('realtime.outboundBytes', $outboundBytes);
Span::add('realtime.projectId', $project?->getId() ?? $projectId);
Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null);
Span::add('realtime.messageType', $messageType);
Span::current()?->finish();
}
});
$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
$projectId = null;
$userId = null;
$subscriptionsBeforeClose = 0;
$success = false;
Span::init('realtime.close');
Span::add('realtime.connectionId', $connection);
if (array_key_exists($connection, $realtime->connections)) {
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
$userId = $realtime->connections[$connection]['userId'] ?? null;
}
try {
if (array_key_exists($connection, $realtime->connections)) {
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
$register->get('telemetry.connectionCounter')->add(-1);
$register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes'));
$subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection));
if ($subscriptionsBeforeClose > 0) {
$register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes'));
}
$projectId = $realtime->connections[$connection]['projectId'];
@@ -1005,12 +1369,30 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
METRIC_REALTIME_CONNECTIONS => -1,
], $projectId);
}
$success = true;
} catch (\Throwable $th) {
// Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine
// backtraces and unsubscribe() below would never run (connection cleanup would fail).
Console::error('Realtime onClose error: ' . $th->getMessage());
Span::error($th);
} finally {
try {
$realtime->unsubscribe($connection);
} catch (\Throwable $th) {
Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage());
Span::error($th);
}
Span::add('realtime.success', $success);
if (!empty($projectId)) {
Span::add('realtime.projectId', $projectId);
}
if (!empty($userId)) {
Span::add('realtime.userId', $userId);
}
Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose);
Span::current()?->finish();
}
$realtime->unsubscribe($connection);
Console::info('Connection close: ' . $connection);
});
+30 -22
View File
@@ -120,7 +120,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_SMTP_HOST
- _APP_SMTP_PORT
- _APP_SMTP_SECURE
@@ -256,7 +255,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_USAGE_STATS
- _APP_LOGGING_CONFIG
@@ -287,7 +285,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-webhooks:
@@ -315,7 +312,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -356,7 +352,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_STORAGE_DEVICE
- _APP_STORAGE_S3_ACCESS_KEY
- _APP_STORAGE_S3_SECRET
@@ -416,7 +411,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-builds:
@@ -453,7 +447,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _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
@@ -529,7 +522,35 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_CONFIG
appwrite-worker-executions:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
entrypoint: worker-executions
<<: *x-logging
container_name: appwrite-worker-executions
restart: unless-stopped
networks:
- appwrite
depends_on:
redis:
condition: service_healthy
<?= $dbService ?>:
condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_ENV
- _APP_DB_ADAPTER
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_CONFIG
appwrite-worker-functions:
@@ -563,7 +584,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_FUNCTIONS_TIMEOUT
- _APP_SITES_TIMEOUT
- _APP_COMPUTE_BUILD_TIMEOUT
@@ -601,7 +621,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -644,7 +663,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_SMS_FROM
- _APP_SMS_PROVIDER
@@ -705,7 +723,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _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
@@ -744,7 +761,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_RETENTION_EXECUTION
- _APP_MAINTENANCE_RETENTION_CACHE
@@ -777,7 +793,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -810,7 +825,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -842,7 +856,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -878,7 +891,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
appwrite-task-scheduler-executions:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
@@ -907,7 +919,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
appwrite-task-scheduler-messages:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
@@ -936,7 +947,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
<?php if ($enableAssistant): ?>
appwrite-assistant:
@@ -964,7 +974,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
<<: *x-logging
restart: unless-stopped
stop_signal: SIGINT
image: openruntimes/executor:0.7.22
image: openruntimes/executor:0.11.4
networks:
- appwrite
- runtimes
@@ -1039,13 +1049,12 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
image: mongo:8.2.5
container_name: appwrite-mongodb
<<: *x-logging
restart: unless-stopped
networks:
- appwrite
volumes:
- appwrite-mongodb:/data/db
- appwrite-mongodb-keyfile:/data/keyfile
ports:
- "27017:27017"
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=${_APP_DB_ROOT_PASS}
@@ -1176,7 +1185,6 @@ volumes:
<?php elseif ($dbService === 'mongodb'): ?>
appwrite-mongodb:
appwrite-mongodb-keyfile:
appwrite-mongodb-config:
<?php endif; ?>
appwrite-redis:
appwrite-cache:
+43 -531
View File
@@ -1,564 +1,69 @@
<?php
require_once __DIR__ . '/init.php';
$registerWorkerMessageResources = require __DIR__ . '/init/worker/message.php';
use Appwrite\Certificates\LetsEncrypt;
use Appwrite\Event\Audit;
use Appwrite\Event\Build;
use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
use Appwrite\Platform\Appwrite;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Swoole\Runtime;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Logger\Logger;
use Utopia\Platform\Service;
use Utopia\Pools\Group;
use Utopia\Queue\Adapter\Swoole;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Message;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Queue\Server;
use Utopia\Registry\Registry;
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
Runtime::enableCoroutine();
require_once __DIR__ . '/init/span.php';
global $register;
Server::setResource('register', fn () => $register);
global $container;
$container->set('pools', function ($register) {
return $register->get('pools');
}, ['register']);
Server::setResource('authorization', function () {
$container->set('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
return $authorization;
}, []);
Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, Authorization $authorization) {
$pools = $register->get('pools');
$adapter = new DatabasePool($pools->get('console'));
$dbForPlatform = new Database($adapter, $cache);
$container->set('project', fn () => new Document([]), []);
$dbForPlatform
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setNamespace('_console')
->setDocumentType('users', User::class);
$container->set('log', fn () => new Log(), []);
return $dbForPlatform;
}, ['cache', 'register', 'authorization']);
Server::setResource('project', function (Message $message, Database $dbForPlatform) {
$payload = $message->getPayload() ?? [];
$project = new Document($payload['project'] ?? []);
if ($project->getId() === 'console') {
return $project;
}
return $dbForPlatform->getDocument('projects', $project->getId());
}, ['message', 'dbForPlatform']);
Server::setResource('dbForProject', function (Cache $cache, Registry $register, Message $message, Document $project, Database $dbForPlatform, Authorization $authorization) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
$pools = $register->get('pools');
try {
$dsn = new DSN($project->getAttribute('database'));
} catch (\InvalidArgumentException) {
// TODO: Temporary until all projects are using shared tables
$dsn = new DSN('mysql://' . $project->getAttribute('database'));
}
$adapter = new DatabasePool($pools->get($dsn->getHost()));
$database = new Database($adapter, $cache);
$database->setDocumentType('users', User::class);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $project->getSequence());
}
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
return $database;
}, ['cache', 'register', 'message', 'project', 'dbForPlatform', 'authorization']);
Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) {
$databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases): Database {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
try {
$dsn = new DSN($project->getAttribute('database'));
} catch (\InvalidArgumentException) {
// TODO: Temporary until all projects are using shared tables
$dsn = new DSN('mysql://' . $project->getAttribute('database'));
}
if (isset($databases[$dsn->getHost()])) {
$database = $databases[$dsn->getHost()];
$database->setAuthorization($authorization);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $project->getSequence());
}
return $database;
}
$adapter = new DatabasePool($pools->get($dsn->getHost()));
$database = new Database($adapter, $cache);
$databases[$dsn->getHost()] = $database;
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $project->getSequence());
}
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
return $database;
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant($project->getSequence());
return $database;
}
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
$database->setTenant($project->getSequence());
}
return $database;
};
}, ['pools', 'cache', 'authorization']);
Server::setResource('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) {
return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database {
$projectDocument ??= $project;
$databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
$databaseType = $database->getAttribute('type', '');
// Backwardscompatibility: older or seeded legacy databases may not have a DSN stored
// in the "database" attribute. In that case, fall back to the project's database DSN.
if ($databaseDSN === '') {
$databaseDSN = $projectDocument->getAttribute('database', '');
}
try {
$databaseDSN = new DSN($databaseDSN);
} catch (\InvalidArgumentException) {
$databaseDSN = new DSN('mysql://'.$databaseDSN);
}
try {
$dsn = new DSN($projectDocument->getAttribute('database'));
} catch (\InvalidArgumentException) {
// Temporary fallback until all projects use shared tables
$dsn = new DSN('mysql://' . $projectDocument->getAttribute('database'));
}
$pools = $register->get('pools');
$pool = $pools->get($databaseDSN->getHost());
$adapter = new DatabasePool($pool);
$database = new Database($adapter, $cache);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization);
$database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
->setTenant((int) $projectDocument->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $projectDocument->getSequence());
}
$database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
return $database;
};
}, ['cache', 'register', 'project', 'authorization']);
Server::setResource('abuseRetention', function () {
return time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day
});
Server::setResource('auditRetention', function (Document $project) {
if ($project->getId() === 'console') {
return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months
}
return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days
}, ['project']);
Server::setResource('executionRetention', function () {
return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days
});
Server::setResource('cache', function (Registry $register) {
$pools = $register->get('pools');
$list = Config::getParam('pools-cache', []);
$adapters = [];
foreach ($list as $value) {
$adapters[] = new CachePool($pools->get($value));
}
return new Cache(new Sharding($adapters));
}, ['register']);
Server::setResource('redis', function () {
$host = System::getEnv('_APP_REDIS_HOST', 'localhost');
$port = System::getEnv('_APP_REDIS_PORT', 6379);
$pass = System::getEnv('_APP_REDIS_PASS', '');
$redis = new \Redis();
@$redis->pconnect($host, (int) $port);
if ($pass) {
$redis->auth($pass);
}
$redis->setOption(\Redis::OPT_READ_TIMEOUT, -1);
return $redis;
});
Server::setResource('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
};
}, ['redis']);
Server::setResource('log', fn () => new Log());
Server::setResource('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
Server::setResource('publisherDatabases', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherFunctions', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
Server::setResource('consumer', function (Group $pools) {
$container->set('consumer', function (Group $pools) {
return new BrokerPool(consumer: $pools->get('consumer'));
}, ['pools']);
Server::setResource('consumerDatabases', function (BrokerPool $consumer) {
$container->set('consumerDatabases', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
Server::setResource('consumerMigrations', function (BrokerPool $consumer) {
$container->set('consumerMigrations', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) {
$container->set('consumerStatsUsage', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
Server::setResource('usage', function () {
return new Context();
}, []);
Server::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
Server::setResource('queueForDatabase', function (Publisher $publisher) {
return new EventDatabase($publisher);
}, ['publisher']);
Server::setResource('queueForMessaging', function (Publisher $publisher) {
return new Messaging($publisher);
}, ['publisher']);
Server::setResource('queueForMails', function (Publisher $publisher) {
return new Mail($publisher);
}, ['publisher']);
Server::setResource('queueForBuilds', function (Publisher $publisher) {
return new Build($publisher);
}, ['publisher']);
Server::setResource('queueForScreenshots', function (Publisher $publisher) {
return new Screenshot($publisher);
}, ['publisher']);
Server::setResource('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
Server::setResource('queueForEvents', function (Publisher $publisher) {
return new Event($publisher);
}, ['publisher']);
Server::setResource('queueForAudits', function (Publisher $publisher) {
return new Audit($publisher);
}, ['publisher']);
Server::setResource('queueForWebhooks', function (Publisher $publisher) {
return new Webhook($publisher);
}, ['publisher']);
Server::setResource('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
Server::setResource('queueForRealtime', function () {
return new Realtime();
}, []);
Server::setResource('queueForCertificates', function (Publisher $publisher) {
return new Certificate($publisher);
}, ['publisher']);
Server::setResource('queueForMigrations', function (Publisher $publisher) {
return new Migration($publisher);
}, ['publisher']);
Server::setResource('logger', function (Registry $register) {
return $register->get('logger');
}, ['register']);
Server::setResource('pools', function (Registry $register) {
return $register->get('pools');
}, ['register']);
Server::setResource('telemetry', fn () => new NoTelemetry());
Server::setResource('deviceForSites', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForFunctions', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForFiles', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForBuilds', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForCache', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
Server::setResource('plan', function (array $plan = []) {
return [];
});
Server::setResource('certificates', function () {
$container->set('certificates', function () {
$email = System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS'));
if (empty($email)) {
throw new Exception('You must set a valid security email address (_APP_EMAIL_CERTIFICATES) to issue a LetsEncrypt SSL certificate.');
}
return new LetsEncrypt($email);
});
}, []);
Server::setResource('logError', function (Registry $register, Document $project) {
return function (Throwable $error, string $namespace, string $action, ?array $extras = null) use ($register, $project) {
$logger = $register->get('logger');
if ($logger) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$log = new Log();
$log->setNamespace($namespace);
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', get_class($error));
$log->addTag('projectId', $project->getId() ?? '');
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
if ($error->getPrevious() !== null) {
if ($error->getPrevious()->getMessage() != $error->getMessage()) {
$log->addExtra('previousMessage', $error->getPrevious()->getMessage());
}
$log->addExtra('previousFile', $error->getPrevious()->getFile());
$log->addExtra('previousLine', $error->getPrevious()->getLine());
}
foreach (($extras ?? []) as $key => $value) {
$log->addExtra($key, $value);
}
$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::warning("Failed: {$error->getMessage()}");
Console::warning($error->getTraceAsString());
if ($error->getPrevious() !== null) {
if ($error->getPrevious()->getMessage() != $error->getMessage()) {
Console::warning("Previous Failed: {$error->getPrevious()->getMessage()}");
}
Console::warning("Previous File: {$error->getPrevious()->getFile()} Line: {$error->getPrevious()->getLine()}");
}
};
}, ['register', 'project']);
Server::setResource('executor', fn () => new Executor());
Server::setResource('getAudit', function (Database $dbForPlatform, callable $getProjectDB) {
return function (Document $project) use ($dbForPlatform, $getProjectDB) {
if ($project->isEmpty() || $project->getId() === 'console') {
$adapter = new AdapterDatabase($dbForPlatform);
return new UtopiaAudit($adapter);
}
$dbForProject = $getProjectDB($project);
$adapter = new AdapterDatabase($dbForProject);
return new UtopiaAudit($adapter);
};
}, ['dbForPlatform', 'getProjectDB']);
Server::setResource('executionsRetentionCount', function (Document $project, array $plan) {
if ($project->getId() === 'console' || empty($plan)) {
return 0;
}
return (int) ($plan['executionsRetentionCount'] ?? 100);
}, ['project', 'plan']);
$pools = $register->get('pools');
$platform = new Appwrite();
$args = $platform->getEnv('argv');
$args = $_SERVER['argv'] ?? [];
if (! isset($args[1])) {
Console::error('Missing worker name');
@@ -574,38 +79,45 @@ if (\str_starts_with($workerName, 'databases')) {
$queueName = System::getEnv('_APP_QUEUE_NAME', 'v1-' . strtolower($workerName));
}
/** @var \Utopia\Pools\Group $pools */
$pools = $container->get('pools');
$adapter = new Swoole(
$pools->get('consumer')->pop()->getResource(),
System::getEnv('_APP_WORKERS_NUM', 1),
$queueName
);
$worker = new Server($adapter, $container);
try {
/**
* Any worker can be configured with the following env vars:
* - _APP_WORKERS_NUM The total number of worker processes
* - _APP_WORKER_PER_CORE The number of worker processes per core (ignored if _APP_WORKERS_NUM is set)
* - _APP_QUEUE_NAME The name of the queue to read for database events
*/
$worker->init()->action(function () use ($worker, $registerWorkerMessageResources) {
$registerWorkerMessageResources($worker->getContainer());
});
$container->set('bus', function ($register) use ($worker) {
return $register->get('bus')->setResolver(
fn (string $name) => $worker->getContainer()->get($name)
);
}, ['register']);
$platform->setWorker($worker);
$platform->init(Service::TYPE_WORKER, [
'workersNum' => System::getEnv('_APP_WORKERS_NUM', 1),
'connection' => $pools->get('consumer')->pop()->getResource(),
'workerName' => strtolower($workerName) ?? null,
'queueName' => $queueName,
'workerName' => strtolower($workerName),
]);
} catch (\Throwable $e) {
Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
Console::exit(1);
}
$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')
->inject('logger')
->inject('log')
->inject('pools')
->inject('project')
->inject('authorization')
->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($queueName) {
->action(function (Throwable $error, ?Logger $logger, Log $log, Document $project, Authorization $authorization) use ($queueName) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
if ($logger) {
@@ -617,7 +129,7 @@ $worker
$log->setAction('appwrite-queue-' . $queueName);
$log->addTag('verboseType', get_class($error));
$log->addTag('code', $error->getCode());
$log->addTag('projectId', $project->getId() ?? 'n/a');
$log->addTag('projectId', $project->getId());
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
+8 -9
View File
@@ -52,34 +52,34 @@
"appwrite/php-runtimes": "0.19.*",
"appwrite/php-clamav": "2.0.*",
"utopia-php/abuse": "1.2.*",
"utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
"utopia-php/audit": "2.2.*",
"utopia-php/auth": "0.5.*",
"utopia-php/cache": "1.0.*",
"utopia-php/cli": "0.22.*",
"utopia-php/cli": "0.23.*",
"utopia-php/compression": "0.1.*",
"utopia-php/config": "1.*",
"utopia-php/console": "0.1.*",
"utopia-php/database": "5.*",
"utopia-php/agents": "1.*",
"utopia-php/detector": "0.2.*",
"utopia-php/domains": "1.*",
"utopia-php/emails": "0.6.*",
"utopia-php/dns": "1.6.*",
"utopia-php/dsn": "0.2.1",
"utopia-php/framework": "0.33.*",
"utopia-php/http": "0.34.*",
"utopia-php/fetch": "0.5.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.22.*",
"utopia-php/migration": "1.9.*",
"utopia-php/platform": "0.7.*",
"utopia-php/platform": "0.13.*",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
"utopia-php/preloader": "0.2.*",
"utopia-php/queue": "0.15.*",
"utopia-php/servers": "0.2.5",
"utopia-php/queue": "0.17.*",
"utopia-php/servers": "0.3.*",
"utopia-php/registry": "0.5.*",
"utopia-php/storage": "1.0.*",
"utopia-php/system": "0.10.*",
@@ -92,10 +92,9 @@
"chillerlan/php-qrcode": "4.3.*",
"adhocore/jwt": "1.1.*",
"spomky-labs/otphp": "11.*",
"webonyx/graphql-php": "14.11.*",
"webonyx/graphql-php": "15.31.*",
"league/csv": "9.14.*",
"enshrined/svg-sanitize": "0.22.*",
"utopia-php/di": "0.1.0"
"enshrined/svg-sanitize": "0.22.*"
},
"require-dev": {
"ext-fileinfo": "*",
Generated
+250 -242
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -242,7 +242,6 @@ services:
- _APP_EXPERIMENT_LOGGING_PROVIDER
- _APP_EXPERIMENT_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
- _APP_DATABASE_SHARED_TABLES_V1
- _APP_DATABASE_SHARED_NAMESPACE
- _APP_FUNCTIONS_CREATION_ABUSE_LIMIT
- _APP_CUSTOM_DOMAIN_DENY_LIST
@@ -254,7 +253,7 @@ services:
appwrite-console:
<<: *x-logging
container_name: appwrite-console
image: appwrite/console:7.8.26
image: appwrite/console:7.8.45
restart: unless-stopped
networks:
- appwrite
@@ -462,7 +461,6 @@ services:
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
- _APP_DATABASE_SHARED_TABLES
- _APP_DATABASE_SHARED_TABLES_V1
- _APP_EMAIL_CERTIFICATES
- _APP_MAINTENANCE_RETENTION_AUDIT
- _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE
@@ -1288,6 +1286,7 @@ services:
image: mongo:8.2.5
container_name: appwrite-mongodb
<<: *x-logging
restart: on-failure:3
networks:
- appwrite
volumes:
@@ -1 +0,0 @@
Initialize an MFA challenge of the specified factor. The factor must be available on the account.
@@ -1 +0,0 @@
Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client.
@@ -1 +0,0 @@
Get the collection activity logs list by its unique ID.
@@ -1 +0,0 @@
Get the document activity logs list by its unique ID.
@@ -1 +0,0 @@
List attributes in the collection.
@@ -1 +0,0 @@
Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build.
@@ -1,5 +0,0 @@
Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID.
This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions).
Use the "command" param to set the entrypoint used to execute your code.
@@ -1 +0,0 @@
Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously.
@@ -1 +0,0 @@
Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API.
@@ -1 +0,0 @@
Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables.
@@ -1 +0,0 @@
Delete a code deployment by its unique ID.
@@ -1 +0,0 @@
Delete a function execution by its unique ID.
@@ -1 +0,0 @@
Delete a function by its unique ID.
@@ -1 +0,0 @@
Delete a variable by its unique ID.
@@ -1 +0,0 @@
Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download.
@@ -1 +0,0 @@
Get a code deployment by its unique ID.
@@ -1 +0,0 @@
Get a function execution log by its unique ID.
@@ -1 +0,0 @@
Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days.
@@ -1 +0,0 @@
Get a function by its unique ID.
@@ -1 +0,0 @@
Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days.
@@ -1 +0,0 @@
Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method.
@@ -1 +0,0 @@
Get a variable by its unique ID.
@@ -1 +0,0 @@
Get a list of all the function's code deployments. You can use the query params to filter your results.
@@ -1 +0,0 @@
Get a list of all the current user function execution logs. You can use the query params to filter your results.
@@ -1 +0,0 @@
Get a list of all the project's functions. You can use the query params to filter your results.
@@ -1 +0,0 @@
Get a list of all runtimes that are currently active on your instance.
@@ -1 +0,0 @@
List allowed function specifications for this instance.
@@ -1 +0,0 @@
List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method.
@@ -1 +0,0 @@
Get a list of all variables of a specific function.
@@ -1 +0,0 @@
Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details.
@@ -1 +0,0 @@
Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint.
@@ -1 +0,0 @@
Update function by its unique ID.
@@ -1 +0,0 @@
Update variable by its unique ID.
@@ -1 +0,0 @@
Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server.
@@ -1 +0,0 @@
Get the number of tasks that are waiting to be processed in the Appwrite internal queue server.
-1
View File
@@ -1 +0,0 @@
Check the Appwrite queue messaging servers are up and connection is successful.
-1
View File
@@ -1 +0,0 @@
Delete a message by its unique ID.
@@ -1 +0,0 @@
Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime.
@@ -1 +0,0 @@
Delete a project variable by its unique ID.
-1
View File
@@ -1 +0,0 @@
Get a project variable by its unique ID.
@@ -1 +0,0 @@
Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime.
@@ -1 +0,0 @@
Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime.
-1
View File
@@ -1 +0,0 @@
Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
@@ -1 +0,0 @@
Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API.
@@ -1 +0,0 @@
Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur.
-1
View File
@@ -1 +0,0 @@
Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls.
@@ -1 +0,0 @@
Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project.
@@ -1 +0,0 @@
Reset a custom SMS template to its default value. This endpoint removes any custom message and restores the template to its original state.
@@ -1 +0,0 @@
Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events.
-1
View File
@@ -1 +0,0 @@
Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes.
-1
View File
@@ -1 +0,0 @@
Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations.
@@ -1 +0,0 @@
Get a custom SMS template for the specified locale and type returning it's contents.
-1
View File
@@ -1 +0,0 @@
Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project.

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