Compare commits

...
Author SHA1 Message Date
Prem Palanisamy c71fa9d1bc Restore region check alongside host check 2026-05-24 13:48:59 +01:00
Prem Palanisamy 8f1f485184 Resolve custom-domain rule into allowlist before host check 2026-05-24 13:43:03 +01:00
Prem Palanisamy 927acbefb2 Scope rules lookup to source project 2026-05-24 13:33:43 +01:00
Prem Palanisamy 14616fd2eb Query rules by source host directly instead of loading project rules 2026-05-24 13:29:56 +01:00
Prem Palanisamy 0cdb13b398 Apply host check to Appwrite-to-CSV/JSON exports too 2026-05-24 13:25:36 +01:00
Prem Palanisamy f7baeae70c Drop loopback rewrite + helpers from OSS 2026-05-24 13:07:58 +01:00
Prem Palanisamy 9c774acf02 Trim verbose host-check comments 2026-05-24 12:43:15 +01:00
Prem Palanisamy 9c1a82bcb6 Remove loopback exception from host allowlist
Per Greptile P0: loopback as an unconditional "local" host is exploitable.
An attacker submitting endpoint=http://localhost/v1 with a victim's
projectId would bypass the host-validation gate and hit the DB fast path
against the victim's DSN. Dev clusters that genuinely use localhost as the
public domain already pass the host check via the Hostname validator
matching _APP_DOMAIN=localhost — no special-case needed.
2026-05-24 11:11:03 +01:00
Prem Palanisamy adb4665b20 Accept loopback addresses as local source endpoints
A worker container can never legitimately fetch source data from its own
loopback, but the address still means "this same cluster" to whoever wrote
it (internal automation, dev/single-node setups, console pasting localhost
in OSS dev). Treat it as local so the DB fast path is used and downstream
SDK fallbacks within the migration get the worker-host rewrite they need.
2026-05-24 09:42:13 +01:00
Prem Palanisamy fb79505503 Gate loopback rewrite to DB fast path only
Unconditional rewrite on the SDK path could silently redirect a
misrouted external migration to this cluster's own internal API — if
the caller had a valid apiKey for any project on this cluster, the
migration would read from us instead of the typed external source.
Restore the original DB-only gate.
2026-05-23 16:04:02 +01:00
Prem Palanisamy 7f4066bb86 Use Utopia Hostname validator for source-host allowlist matching
Mirrors src/Appwrite/Network/Cors.php, which already uses Hostname for
CORS allow-listing. Replaces the inline exact + suffix-with-dot logic
with the validator's built-in exact + wildcard handling (*.domain).
Normalizing _APP_DOMAIN through parse_url too closes the same
port-suffix gap that the previous commit closed for _APP_MIGRATION_HOST.
2026-05-23 15:37:19 +01:00
Prem Palanisamy c5fe22e4be Strip port from _APP_MIGRATION_HOST before host equality check
parse_url(PHP_URL_HOST) strips the port from the source URL, so an env
value like 'appwrite:8080' would never equal the parsed source host
'appwrite'. Normalize the env value through the same parse_url so the
local-fast-path routing still triggers when the deployment publishes
the migration host with an explicit port.
2026-05-23 15:25:58 +01:00
Prem Palanisamy 12a6a1f482 Trim verbose comments to essentials 2026-05-23 14:49:25 +01:00
Prem Palanisamy ced8594752 Mirror cloud worker: empty-endpoint fallback + loopback rewrite
Three parity changes with appwrite-labs/cloud#4103:

1. Empty endpoint clause in $isLocalEndpoint so callers bypassing
   processMigration's defaulting (e.g. unit tests) still get the DB path
   when _APP_MIGRATION_HOST is configured.

2. resolveLocalEndpoint + getMigrationEndpoint helpers, with the inline
   construction in processMigration consolidated to use them.

3. Always rewrite loopback source URLs (localhost / 127.0.0.1 / etc.)
   to the internal host for SourceAppwrite, on both DB and SDK paths.
   Loopback addresses are unreachable from inside the worker container;
   the rewrite preserves legit dev scenarios. SDK auth still gates access.
2026-05-23 14:40:12 +01:00
Prem Palanisamy 7f5b0225b7 Trim verbose comment in source-resolution check 2026-05-22 18:09:43 +01:00
Prem Palanisamy 2eb5aca116 Accept _APP_MIGRATION_HOST too when matching local source endpoint
_APP_DOMAIN is the public host users paste from console, but programmatic
migrations that leave `endpoint` blank default to _APP_MIGRATION_HOST in
processMigration. Both are valid references to this cluster; checking only
_APP_DOMAIN rejected the second pattern (e.g. CI tests pass the internal
host directly) and routed legitimate intra-cluster migrations to SDK.
2026-05-22 17:58:14 +01:00
Prem Palanisamy 2b08313cda Use parse_url + str_ends_with for endpoint check
Substring matching on the full URL hit false positives in three ways:
the domain in a query string, a `customer.com.attacker.io` suffix attack,
and `fake-customer.com` superstring matches. Parse the host out of the
URL and match against `_APP_DOMAIN` exactly or as a `.`-prefixed suffix
(to allow region subdomains like `fra.localhost`).
2026-05-22 15:26:48 +01:00
premtsd-codeandGitHub a91796db14 Merge branch '1.9.x' into fix-migrations-trust-endpoint-for-source 2026-05-22 13:19:09 +01:00
Prem Palanisamy 5f96db51c2 Use endpoint substring match against _APP_DOMAIN for local source
The apiKey-ownership approach required reading project keys and was specific to
the bug case. The simpler endpoint check covers the same bug — same projectId
on source and destination only means "local" when the source endpoint actually
contains this installation's public domain.
2026-05-22 13:02:51 +01:00
Harsh MahajanandGitHub 8436fb0175 Merge pull request #12375 from appwrite/fix/organization-projects-api
Fix: organization project API headers and queries
2026-05-22 16:46:45 +05:30
harsh mahajan 6a4dcd76a7 Keep organization projects schema unchanged 2026-05-22 15:45:32 +05:30
harsh mahajan e831b3e952 Add project status index 2026-05-22 15:39:16 +05:30
Prem Palanisamy 249bc80a3b Use array_map + in_array for project key match 2026-05-22 11:05:54 +01:00
harsh mahajan 4dec4f2ff2 Fix organization project API headers and queries 2026-05-22 15:32:44 +05:30
Prem Palanisamy b6583e231c Iterate keys in PHP instead of querying encrypted secret field 2026-05-22 11:00:40 +01:00
Prem Palanisamy 0ca4234d94 Reuse isAppwriteToAppwrite for credential defaulting check 2026-05-22 10:27:23 +01:00
Prem Palanisamy e1ebdd1c4e Skip credential defaulting for Appwrite -> Appwrite migrations 2026-05-22 10:23:48 +01:00
Prem Palanisamy f3955f92b2 Split source resolution into readable early-return style 2026-05-22 10:08:28 +01:00
Prem Palanisamy 0a2d29ecd5 Drop local candidate var, use $this->sourceProject directly 2026-05-22 10:03:13 +01:00
Prem Palanisamy 28b065463f Inline source resolution back into processSource 2026-05-22 09:58:11 +01:00
Prem Palanisamy 3684cdc588 Extract canReadSourceFromLocalDb helper 2026-05-22 09:53:47 +01:00
premtsd-codeandGitHub 2bfa65f518 Merge branch '1.9.x' into fix-migrations-trust-endpoint-for-source 2026-05-22 09:47:07 +01:00
Prem Palanisamy 4e5a624e06 Drop redundant !== false on Database::findOne result 2026-05-22 09:45:37 +01:00
Prem Palanisamy 0c367acd6d Short-circuit to SDK path when local source lookup is empty 2026-05-22 09:37:42 +01:00
Prem Palanisamy a0e8fe92fe Hoist credential guard to start of processSource 2026-05-22 09:26:29 +01:00
Prem Palanisamy e35cfa928a Verify apiKey ownership to safely take DB fast path for SourceAppwrite 2026-05-22 09:22:01 +01:00
Harsh MahajanandGitHub aa4c6b6e8f Merge pull request #12374 from appwrite/fix/smtp-test-email-missing-platform-vars
Fix missing platform variables in SMTP test email template
2026-05-22 13:39:59 +05:30
harsh mahajan 2628cc31fc Remove unused $plan injection 2026-05-22 13:12:50 +05:30
harsh mahajanandHarsh Mahajan e3768ce8ee Fix missing platform variables in SMTP test email template
The SMTP test email uses email-base-styled.tpl as its base template,
which contains {{platform}}, {{logoUrl}}, {{accentColor}}, and social/
legal link placeholders. These were never passed as template variables,
causing them to render as literal strings (e.g. "{{platform}} logo").

Inject the platform config and pass the variables to MailMessage,
matching the pattern used by OTP and magic-url email flows.

Co-Authored-By: Harsh Mahajan <harsh@appwrite.io>
2026-05-22 13:03:48 +05:30
Jake BarnbyandGitHub dfc5f1c8dc Merge pull request #12371 from appwrite/fix-project-smtp-port-type
fix(project): cast smtpPort to int in response model
2026-05-22 19:09:07 +12:00
Harsh MahajanandGitHub e375009a49 Merge pull request #12373 from appwrite/feat-ser-401-expose-build-trigger-fields
Expose providerBranches and providerPaths in Function and Site response
2026-05-22 12:36:59 +05:30
harsh mahajanandHarsh Mahajan cf1623bc91 Expose providerBranches and providerPaths in Function and Site response models
These fields were already persisted on update but omitted from the response
model, causing them to disappear after a page refresh in the console.

Co-Authored-By: Harsh Mahajan <harsh@appwrite.io>
2026-05-22 12:27:05 +05:30
Prem Palanisamy e364082577 fix(project): cast smtpPort to int in response model 2026-05-22 07:52:25 +01:00
Prem Palanisamy 507f9d6c6c Require explicit endpoint + apiKey for external Appwrite sources 2026-05-22 07:16:18 +01:00
Prem Palanisamy cbf2fd6e6a fix(migrations): support external Appwrite sources 2026-05-22 06:58:19 +01:00
Luke B. SilverandGitHub 9951ccdca3 Merge pull request #12338 from appwrite/fix/coroutines-correctness
chore: migrate to utopia-php/http feat-safe-wildcards
2026-05-21 21:58:55 +01:00
loks0nandClaude Opus 4.7 035cbacbfb Use 'params' injection in V20 filter
Drops the redundant Route::resolveParams() call. The matched path
params are now provided directly via the new frame-local 'params'
injection from utopia-php/http, avoiding a second URL parse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:09:55 +01:00
loks0nandClaude Opus 4.7 690c72b789 Use 'route' injection in remaining api/auth init hooks
The 'route' injection is frame-local and non-nullable inside a
matched action's hooks. Replaces \$utopia->match() lookups in api.php
and auth.php init hooks, drops the dead \$utopia inject from the
session shutdown hook, and removes the now-redundant null guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:09:41 +01:00
loks0nandClaude Opus 4.7 dd0c8af160 Use 'route' injection in shutdown hooks
The 'route' injection introduced in utopia-php/http feat-safe-wildcards
is frame-local and non-nullable inside a matched action's hooks, so
the shutdown handlers in api.php and mock.php no longer need to call
match() and dereference a nullable result.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:09:41 +01:00
loks0nandClaude Opus 4.7 933fffd763 Use route template instead of request URI for documentsdb path check
The documentsdb/vectorsdb routes are registered with
setHttpPath('/v1/documentsdb/...') with no aliases, so getPath()
returns a template containing the substring we're matching against
— and matches the prior getMatchedPath() semantics without depending
on the raw request URI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:09:41 +01:00
loks0nandClaude Opus 4.7 1538927486 chore: migrate to utopia-php/http feat-safe-wildcards
Adopts the new safe-wildcard dispatch primitive from
utopia-php/http#feat-safe-wildcards. Http::execute() is now the
re-entrant dispatch entry point and Http::match() is pure (returns
?RouteMatch). The removed Http::getRoute()/setRoute(),
Route::getMatchedPath(), Route::getPathValues() callsites are migrated
to the new API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:09:41 +01:00
Harsh MahajanandGitHub 49bf24d388 Merge pull request #11955 from appwrite/feat-ser-401-custom-triggers
Feat: custom triggers for VCS builds
2026-05-21 19:08:38 +05:30
harsh mahajan f62d765bbf format 2026-05-21 18:46:18 +05:30
harsh mahajanandClaude Sonnet 4.6 268dd50106 Use Globstar validator directly, remove BuildTrigger wrapper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:43:34 +05:30
Harsh MahajanandGitHub 4eb956b820 Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-05-21 16:37:24 +05:30
harsh mahajan 01a6d6e4d6 Inline VCS build trigger checks 2026-05-21 15:35:44 +05:30
harsh mahajan c1e902c79c Restore deployments on branch creation 2026-05-21 15:32:35 +05:30
harsh mahajan 9bcb4d7ca7 Clarify renamed PR file filtering 2026-05-21 15:31:05 +05:30
Matej BačoandGitHub f77205295b Merge pull request #12309 from appwrite/feat-public-organization-api
Feat: Public organization API
2026-05-21 10:25:59 +02:00
Matej BačoandGitHub 781df9d4cf Merge branch '1.9.x' into feat-public-organization-api 2026-05-21 09:42:32 +02:00
Jake Barnby 238b4e447d test: stabilize function logging executions 2026-05-21 18:38:59 +12:00
Jake Barnby 349ac6bbc1 test: stabilize account log pagination 2026-05-21 16:47:45 +12:00
Jake BarnbyandGitHub dca75b059b Merge pull request #12361 from appwrite/fix-smtp-template-regressions
Fix SMTP empty sender regressions
2026-05-21 12:42:51 +12:00
Jake Barnby fd145e988a test: align versions endpoint expectations 2026-05-21 12:18:11 +12:00
Jake Barnby c6ac0f28e4 chore: restore twig security update 2026-05-21 12:16:06 +12:00
Jake Barnby 52d7ebd41e chore: revert to 2.0.0-rc.1 2026-05-21 10:59:37 +12:00
Jake Barnby 41ff2ed48e chore: update lock 2026-05-21 10:37:36 +12:00
harsh mahajan 436064e7cc Revert test fixture change 2026-05-20 23:44:11 +05:30
harsh mahajan a3ae9f39ec Fix SMTP empty sender regressions 2026-05-20 23:40:41 +05:30
Matej Bačo 4400eedbae Add proper test coverage 2026-05-20 13:56:47 +02:00
Matej Bačo cbcf86e362 Add missing delete endpoint 2026-05-20 13:49:40 +02:00
Matej Bačo e69b9eb428 PR cleanup 2026-05-20 13:49:29 +02:00
Harsh MahajanandGitHub 8cfc28f08c Merge pull request #12348 from appwrite/fix-smtp-template-empty-string-params
fix: allow empty string to clear optional SMTP and email template fields
2026-05-20 17:00:14 +05:30
harsh mahajan 5473be5756 chore: revert twig override, restore sdk-generator to 1.31.0 2026-05-20 16:56:56 +05:30
harsh mahajan a3fca62818 chore: bump twig/twig to ^3.26 to fix security advisories 2026-05-20 16:45:17 +05:30
harsh mahajan d397ab9b40 chore: update composer dependencies 2026-05-20 16:34:15 +05:30
Harsh MahajanandGitHub e59e726d38 Merge branch '1.9.x' into fix-smtp-template-empty-string-params 2026-05-20 16:28:06 +05:30
harsh mahajan 8272aa6fa2 fix: remove unnecessary null coalescing on senderEmail 2026-05-20 16:27:52 +05:30
harsh mahajan 43b9bf8152 fix: remove redundant senderEmail empty check before setFrom 2026-05-20 16:25:04 +05:30
Chirag AggarwalandGitHub 5df3280131 Merge pull request #12357 from appwrite/fix/spec-regional-endpoint-validation
Fix generated spec endpoint and enum defaults
2026-05-20 15:21:12 +05:30
Chirag Aggarwal 515696dd36 Fix generated spec endpoint and enum defaults 2026-05-20 14:58:56 +05:30
Chirag AggarwalandGitHub ff76f3efa0 Merge pull request #12356 from appwrite/fix-account-server-specs-auth
Fix account server specs auth
2026-05-20 14:26:14 +05:30
Chirag Aggarwal 0d19894331 Move Web SDK to server platform 2026-05-20 14:09:07 +05:30
Chirag Aggarwal 1d9acf5f29 Fix account SDK specs auth 2026-05-20 14:03:20 +05:30
Harsh MahajanandGitHub d3994b436d Merge branch '1.9.x' into fix-smtp-template-empty-string-params 2026-05-20 11:40:47 +05:30
harsh mahajan cc51f42430 fix: allow SMTP connection test without senderEmail 2026-05-20 11:40:23 +05:30
Matej Bačo 8cf0438a98 Merge branch '1.9.x' into feat-public-organization-api 2026-05-19 17:20:33 +02:00
Matej Bačo b2ebfa9bb9 Revert unwanted tests 2026-05-19 17:20:15 +02:00
Matej BačoandGitHub ed14acfc75 Merge pull request #12353 from appwrite/fix-trace-ending-too-soon
Fix: subspan ending main request span
2026-05-19 16:57:59 +02:00
Matej Bačo 90df8efe0a Formatting fix 2026-05-19 15:50:48 +02:00
Matej Bačo f7b09c090b Fix subspan ending main request span 2026-05-19 15:23:34 +02:00
harsh mahajan 8c903ab687 fix: allow empty string to clear optional SMTP and email template fields 2026-05-19 16:29:55 +05:30
Jake BarnbyandGitHub c5b8535a7f Merge pull request #12233 from appwrite/feat-dedicated-db
feat: dedicated database
2026-05-19 19:44:13 +12:00
Harsh MahajanandGitHub cf101b6a8e Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-05-19 12:35:41 +05:30
Jake BarnbyGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
218cc13b20 Update src/Appwrite/Platform/Workers/StatsResources.php
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-19 18:32:54 +12:00
282b5b2a6c fix(databases): map cursor lookup NotFound to collection 404
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/546b5830-100b-4a48-9d71-f19c204e269f

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-19 06:32:06 +00:00
948c702ad4 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 18:28:20 +12:00
c28f476462 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 18:24:06 +12:00
Jake BarnbyandGitHub 3078cc5941 Merge pull request #12345 from appwrite/codex/advisor-admin-key-auth
fix: restrict advisor auth types
2026-05-19 18:23:06 +12:00
harsh mahajanandClaude Sonnet 4.6 74a4ae19ee refactor: remove unnecessary comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:28:04 +05:30
harsh mahajanandClaude Sonnet 4.6 3b3a04877f feat: remove CommitSkipPatterns - already available in 1.9.x
Keep only branch and path skip trigger logic. Commit message skip
is handled by the existing Contains(VCS_DEPLOYMENT_SKIP_PATTERNS) check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:21:04 +05:30
harsh mahajanandClaude Sonnet 4.6 a4a57bc64d Merge branch '1.9.x' into feat-ser-401-custom-triggers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:20:08 +05:30
Damodar LohaniandGitHub fd91490ae9 Merge pull request #12344 from appwrite/feat/file-size-actual
feat(storage): expose sizeActual in File response model
2026-05-19 09:02:12 +05:45
Jake BarnbyandGitHub 81d78ceb96 Merge branch '1.9.x' into codex/advisor-admin-key-auth 2026-05-19 14:53:56 +12:00
Jake Barnby 9233fe5b42 fix: restrict advisor auth types 2026-05-19 14:47:54 +12:00
Jake BarnbyandGitHub 3c1e07fc92 Merge pull request #12343 from appwrite/presence-api
added category to the presences scopes
2026-05-19 14:47:28 +12:00
Damodar LohaniandClaude Opus 4.7 27534133bb feat(storage): expose sizeActual in File response model
Adds the stored byte size (after compression and/or encryption) alongside
sizeOriginal so clients can see how much space a file actually occupies.
The sizeActual attribute is already persisted on file documents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:11:49 +00:00
Jake Barnby 52b8e1b94d chore: sync 1.9.x into feat-dedicated-db 2026-05-19 13:32:46 +12:00
ArnabChatterjee20k 4d3c60b36c updated 2026-05-18 21:53:06 +05:30
Chirag AggarwalandGitHub 8d93922a52 Merge pull request #12339 from appwrite/chore/remove-router-deployment-fallback
Remove router deployment fallback
2026-05-18 21:50:49 +05:30
ArnabChatterjee20k 89d8c9285a Merge remote-tracking branch 'origin/presence-api' into presence-api 2026-05-18 21:49:59 +05:30
ArnabChatterjee20k ffe06180bc Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-05-18 21:49:37 +05:30
ArnabChatterjee20k 349db8d370 fix: improve error handling for Utopia DB exceptions in realtime API 2026-05-18 21:49:13 +05:30
ArnabChatterjee20kandGitHub 246ff8900b Merge branch '1.9.x' into presence-api 2026-05-18 21:31:56 +05:30
ArnabChatterjee20k 6f28f2e4f7 linting 2026-05-18 21:26:21 +05:30
ArnabChatterjee20k 42144ab9a0 added category to the presences scopes 2026-05-18 21:23:21 +05:30
Luke B. SilverandGitHub 836a89714d Merge pull request #12341 from appwrite/fix/cache-healthcheck
fix(health): use injected cache resource for healthcheck
2026-05-18 14:31:16 +01:00
Chirag AggarwalandGitHub f1d40a8e92 Merge pull request #12332 from appwrite/refactor/shared-di-resources
Consolidate shared DI resources
2026-05-18 18:38:38 +05:30
Chirag Aggarwal 05eead1857 Remove router deployment fallback 2026-05-18 18:34:12 +05:30
loks0nandClaude Opus 4.7 2cd422f4c2 fix(health): use injected cache resource for healthcheck
Inject the live cache resource and ping it directly instead of
rebuilding pool adapters from pools-cache. The container already
constructs the right adapter (pool, multiplexing, none), so the
healthcheck now exercises the actual configured cache.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:58:24 +01:00
Chirag Aggarwal b756a38252 Consolidate shared DI resources 2026-05-18 16:16:23 +05:30
Atharva DeosthaleandGitHub 4f676aac15 Merge pull request #12337 from appwrite/feat/sites-bun-deno-runtimes
feat(sites): allow bun and deno as build runtimes for JS frameworks
2026-05-18 16:12:18 +05:30
ArnabChatterjee20kandGitHub 7a711b7295 Merge pull request #11886 from appwrite/presence-api
Presence api
2026-05-18 15:25:03 +05:30
Atharva Deosthale 03e62dde94 feat(sites): allow bun and deno as build runtimes for JS frameworks
Merge BUN and DENO template runtimes into the runtimes list for every NODE-based Sites framework (analog, angular, nextjs, react, nuxt, vue, sveltekit, astro, tanstack-start, remix, lynx, react-native, vite, other). The Console's build-runtime dropdown reads framework.runtimes, so this is what surfaces bun-1.3 and deno-2.6 in the UI now that openruntimes/open-runtimes#485 has shipped SSR support for both runtimes.

The server-side WhiteList validator on POST /v1/sites already accepts any runtime in the global runtimes config, so CLI and REST clients have been able to pick bun/deno all along; this change only flips the UI gate.

Flutter stays flutter-only.
2026-05-18 15:16:59 +05:30
Shmuel FogelandGitHub b81b818c47 Merge pull request #12335 from appwrite/delete-executions-order-by
Delete executions order by
2026-05-18 11:24:03 +03:00
fogelito 95789b5aec Merge branch '1.9.x' of https://github.com/appwrite/appwrite into delete-executions-order-by 2026-05-18 11:02:30 +03:00
ArnabChatterjee20k 20c0c8081c feat: add documentation for presence API endpoints and update descriptions 2026-05-18 12:30:58 +05:30
fogelito 254606c4f9 Add orderDesc 2026-05-18 10:00:24 +03:00
Chirag AggarwalandGitHub 7cef1202f1 Merge pull request #12330 from appwrite/fix-unity-sdk-getting-started
Add Unity SDK getting started docs
2026-05-18 12:29:40 +05:30
ArnabChatterjee20k ff298cb206 updated clousure scope 2026-05-18 11:48:52 +05:30
ArnabChatterjee20k 9263ea3661 Merge remote-tracking branch 'origin/presence-api' into presence-api 2026-05-18 11:32:55 +05:30
ArnabChatterjee20k 8aa09cfcc1 fix: update checkForProjectUsage function to return void instead of Document 2026-05-18 11:32:37 +05:30
Chirag Aggarwal ab208a01b6 Update Unity docs for client factories 2026-05-18 11:22:24 +05:30
ArnabChatterjee20kandGitHub 80fffa32b8 Merge branch '1.9.x' into presence-api 2026-05-18 11:09:55 +05:30
ArnabChatterjee20k 664c24173e feat: update checkForProjectUsage function to return the project document 2026-05-18 11:06:33 +05:30
Chirag Aggarwal 5b3af53bdd Address Unity SDK docs review 2026-05-18 11:04:07 +05:30
ArnabChatterjee20k b2809c911d fix: improve presence log deletion handling and ensure accurate event triggering 2026-05-18 11:02:21 +05:30
Chirag Aggarwal febe332050 Add Unity SDK getting started docs 2026-05-18 10:56:29 +05:30
ArnabChatterjee20k d18de7d1e8 feat: add checkForProjectUsage function and integrate it into message handling 2026-05-18 10:42:11 +05:30
Luke B. SilverandGitHub 4e39506bca Merge pull request #12326 from appwrite/chore/bump-cace
chore: bump utopia-php/cache to ^3.0
2026-05-17 20:56:32 +01:00
loks0nandClaude Opus 4.7 1b945bdeea chore: bump utopia-php/cache to ^3.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:11:05 +01:00
Torsten DittmannandGitHub 4a44af7308 Merge pull request #12209 from appwrite/fix/parallel-storage-chunk-upload
Fix parallel chunked uploads with pooled locks
2026-05-15 22:22:41 +04:00
Torsten Dittmann 37921a5694 Remove utopia-php/lock vcs repo 2026-05-15 21:20:20 +04:00
Torsten Dittmann 27bf229524 Simplify storage test assumptions 2026-05-15 21:12:14 +04:00
Torsten DittmannandGitHub e873f02b94 Merge branch '1.9.x' into fix/parallel-storage-chunk-upload 2026-05-15 20:17:08 +04:00
Torsten Dittmann 5750155591 Add pool-backed lock resource 2026-05-15 19:54:44 +04:00
Harsh MahajanandGitHub d2dcd82efa Merge pull request #12318 from appwrite/feat-email-template-reset-default
feat: add email template reset and get-default endpoints
2026-05-15 20:27:39 +05:30
harsh mahajan cb80f0e9c8 Remove email template integration test 2026-05-15 19:44:20 +05:30
harsh mahajan 6a2ddb221c Clean up template update diff and add locale test 2026-05-15 19:34:15 +05:30
harsh mahajan 6428b73966 Fix email template update System import 2026-05-15 19:05:01 +05:30
harsh mahajan 7b718e7dcc Add email template default integration test 2026-05-15 18:57:08 +05:30
harsh mahajan 15422dc7ca Remove email template reset API changes 2026-05-15 18:51:22 +05:30
harsh mahajan 0e7c50c181 Align console email template endpoint metadata 2026-05-15 18:45:38 +05:30
harsh mahajan 8781942633 Fix console email template spec enum names 2026-05-15 18:18:10 +05:30
harsh mahajan a614afe5c7 refactor: move email template defaults to console module and add tests
- Move GetDefault endpoint to Console module as Get.php
  (GET /v1/console/templates/email/:templateId, console.getEmailTemplate)
- Remove from Project module
- Add e2e tests for console.getEmailTemplate and reset=true
2026-05-15 18:03:12 +05:30
ArnabChatterjee20k cf80efbc3c feat: add support for 'getUsage' method in presences with range parameter 2026-05-15 18:01:32 +05:30
ArnabChatterjee20k ec1fad20dd Merge remote-tracking branch 'origin/presence-api' into presence-api 2026-05-15 17:51:33 +05:30
ArnabChatterjee20k 2c2e8bcabd fix: add expiration check for presence updates to prevent outdated entries 2026-05-15 17:51:08 +05:30
Torsten Dittmann b7bf14a18a Fix storage multipart upload preparation 2026-05-15 15:58:48 +04:00
harsh mahajan 0e157efabe chore: fix import ordering in Http.php 2026-05-15 17:17:32 +05:30
harsh mahajan 69735b383b feat: add email template reset and get-default endpoints
- Add GET /v1/project/templates/email/:templateId/default that returns
  Appwrite built-in defaults, ignoring any custom project overrides
- Add reset param to PATCH /v1/project/templates/email — when reset=true,
  clears the custom template entry and returns defaults without requiring
  SMTP to be enabled
2026-05-15 17:13:35 +05:30
Matej Bačo b627a7d6ff Org key tests 2026-05-15 12:46:27 +02:00
ArnabChatterjee20kandGitHub 14381f2268 Merge branch '1.9.x' into presence-api 2026-05-15 15:53:51 +05:30
Torsten Dittmann 97d6656687 Fix deployment upload lock TTL 2026-05-15 14:17:14 +04:00
Torsten Dittmann e6f3061103 Pin utopia-php/lock to 0.2.x 2026-05-15 14:15:08 +04:00
Torsten Dittmann 9acfb2a679 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into fix/parallel-storage-chunk-upload 2026-05-15 14:02:58 +04:00
Torsten Dittmann c5123529ee Update graphql-php to 15.32 and fix storage tests
- Bump graphql-php to 15.32 in composer.json and update composer.lock
- Extend Storage/Files/Create with mergeUploadMetadata and pass path and
  deviceForFiles into the lock callback
- Remove obsolete type hints in Storage/Files/Preview/Get
- Update Storage tests to tolerate S3-backed signatures
2026-05-15 13:54:24 +04:00
Matej Bačo a0c27afec8 Revert test changes 2026-05-15 11:47:52 +02:00
Matej Bačo 5a0bb57db2 linter fix 2026-05-15 11:45:39 +02:00
Matej Bačo d422b7abdc Final reverts 2026-05-15 11:44:47 +02:00
Matej Bačo 34078ad9af Revert unwanted changes 2026-05-15 11:37:04 +02:00
Matej Bačo 2d94704948 Formatting fix 2026-05-15 11:30:06 +02:00
Matej Bačo cd54e91784 Deprecate old projects endpoints 2026-05-15 11:27:19 +02:00
Matej Bačo 79f536445e Revive old endpoints 2026-05-15 11:26:35 +02:00
Matej Bačo 11839c6dcc Remove old endpoint aliases 2026-05-15 11:26:22 +02:00
Matej Bačo a9eb9d47a5 Merge branch '1.9.x' into feat-public-organization-api 2026-05-15 11:21:17 +02:00
Matej BačoandGitHub 246f487687 Merge pull request #12317 from appwrite/feat-org-scopes-console-api
Feat: Org scopes Console API
2026-05-15 11:17:13 +02:00
Matej Bačo 88e7fdbd9e Improve tests 2026-05-15 10:55:13 +02:00
Chirag AggarwalandGitHub aada60bdb1 Merge pull request #12316 from appwrite/chore/queue-database-publisher-migration
Refactor database queue publisher
2026-05-15 14:18:46 +05:30
Chirag AggarwalandGitHub f43e55620b Merge pull request #12315 from appwrite/chore/update-platform-rc2
Update Utopia platform to rc2
2026-05-15 14:13:13 +05:30
Chirag Aggarwal bf3ac31157 chore: retrigger ci 2026-05-15 13:59:10 +05:30
Chirag Aggarwal e9b026647a chore: retrigger ci 2026-05-15 13:57:10 +05:30
Matej Bačo e93a2c7101 Add tests for org scopes console get endpoint 2026-05-15 10:23:40 +02:00
Matej Bačo 8d8a0e883e Add org scopes get console endpoint 2026-05-15 10:23:32 +02:00
Matej Bačo 75587e629e Improve quality of org scopes 2026-05-15 10:23:22 +02:00
Chirag Aggarwal ad88b827e6 Refactor database queue publisher 2026-05-15 13:22:35 +05:30
Chirag Aggarwal 4d1dabac66 Update Utopia platform to rc2 2026-05-15 13:21:35 +05:30
ArnabChatterjee20k a8670ee8fb fix: update authorization handling in message processing to prevent state leakage 2026-05-15 13:17:14 +05:30
Matej Bačo 5b109f2f8d Fix org key usecase 2026-05-14 19:52:14 +02:00
Matej Bačo 3e35b888a7 Fix bug 2026-05-14 19:47:59 +02:00
Matej Bačo bcd2bfe5b2 Fix security 2026-05-14 19:34:49 +02:00
Matej Bačo d7e29a4dae fix more tests 2026-05-14 19:03:14 +02:00
Matej Bačo 3596662fec Update more tests 2026-05-14 18:42:30 +02:00
Matej Bačo f40c7d415c Prioritize org id header 2026-05-14 18:26:47 +02:00
Matej Bačo 176d59e7c9 Fix broken test after breaking change
List ALL projects no longer exists - we now have list project per organization
2026-05-14 18:02:20 +02:00
Matej Bačo bb25a36cf5 Fix project creation 401 expectation tests 2026-05-14 17:53:03 +02:00
Matej Bačo f37e091b77 Fix specs generation 2026-05-14 17:50:36 +02:00
Matej Bačo 18380a679d Fix backwards compatibility 2026-05-14 17:46:13 +02:00
Matej Bačo 2a82604a2b Better backwards compatibility for project creation 2026-05-14 17:36:03 +02:00
Matej Bačo b618bf1353 Fix inability to CRUD projects 2026-05-14 17:26:08 +02:00
ArnabChatterjee20k 0e1cabe47a Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-05-14 18:25:16 +05:30
Harsh MahajanandGitHub 54693d9417 Merge pull request #12278 from appwrite/feat/skip-deployment-commit-patterns
Feat/skip deployment commit patterns
2026-05-14 18:22:06 +05:30
harsh mahajan 437b3bfa10 fix: lint 2026-05-14 18:08:59 +05:30
harsh mahajan 0b07305775 fix: remove redundant comment, rename commitSkip to validator, use getDescription in span 2026-05-14 18:05:04 +05:30
harsh mahajan b5f0ebb37b refactor: replace DeploymentSkipPatterns validator with VCS_DEPLOYMENT_SKIP_PATTERNS constant 2026-05-14 17:59:34 +05:30
harsh mahajan 3cc600d29a fix: revert validators to 0.2.* for auto updates 2026-05-14 17:56:09 +05:30
ArnabChatterjee20k 2cbd8bbb47 linting 2026-05-14 17:33:53 +05:30
ArnabChatterjee20k 12b2acd7f7 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-05-14 17:31:24 +05:30
Harsh MahajanandGitHub 736196b58b Merge branch '1.9.x' into feat/skip-deployment-commit-patterns 2026-05-14 17:31:06 +05:30
harsh mahajan 25772f4585 chore: composer update all dependencies 2026-05-14 17:28:52 +05:30
harsh mahajan e3755679c5 refactor: extend Contains validator, keep only [skip ci] pattern 2026-05-14 17:26:32 +05:30
Matej Bačo aaa870e656 Formatting fixes 2026-05-14 13:52:34 +02:00
Matej Bačo 94274381ff agentic migration fixes 2026-05-14 13:52:25 +02:00
ArnabChatterjee20k 3703da188e refactor: enhance presence management to prevent cross-user overwrites 2026-05-14 17:17:27 +05:30
Chirag AggarwalandGitHub 450c80ca5b Merge pull request #12312 from appwrite/fix/text-response-specs
Fix text response schemas in API specs
2026-05-14 16:56:32 +05:30
Chirag Aggarwal e1547023f6 Fix OpenAPI text redirect response schemas 2026-05-14 16:48:58 +05:30
Matej Bačo 8a4a6d83d4 Manual migration fixes 2026-05-14 13:06:39 +02:00
Chirag Aggarwal 6d0a76cf51 Fix text response schemas in API specs 2026-05-14 16:35:08 +05:30
Chirag AggarwalandGitHub a3fa12e5b3 Merge pull request #12305 from appwrite/chore/refactor-delete-queue-publisher
refactor: Migrate queueForDeletes to DeletePublisher and DeleteMessage
2026-05-14 16:31:03 +05:30
Chirag AggarwalandGitHub 659bc0dab7 Merge pull request #12307 from appwrite/codex/delete-orphaned-rules-1-9
[codex] Delete orphaned proxy rules on create
2026-05-14 16:28:33 +05:30
Matej Bačo b524b9f934 Fix scopes 2026-05-14 12:18:09 +02:00
premtsd-codeandGitHub 927a5cddc4 Merge pull request #11439 from appwrite/feat-platform-db-access
Add platform migration support
2026-05-14 11:13:53 +01:00
Chirag Aggarwal 6b51a56b2b Tidy deleteOrphanedRule conditionals 2026-05-14 15:42:52 +05:30
Chirag Aggarwal 8f481d6f2c refactor: keep delete publisher in shared container 2026-05-14 15:37:04 +05:30
Chirag Aggarwal 421a9974af Delete orphaned proxy rules on create 2026-05-14 15:33:29 +05:30
Matej Bačo 275a6fe078 merge endpoints as aliases 2026-05-14 12:01:51 +02:00
Matej Bačo 8b4c90e603 Introduce organization endpoints 2026-05-14 11:47:46 +02:00
Chirag Aggarwal 65d1e58e34 refactor: migrate delete queue publisher 2026-05-14 14:40:11 +05:30
ArnabChatterjee20k a4acd000c4 linting 2026-05-14 13:57:35 +05:30
ArnabChatterjee20k f7e5e825f2 Merge remote-tracking branch 'origin/presence-api' into presence-api 2026-05-14 13:29:19 +05:30
ArnabChatterjee20k 7dd85f9143 add ownership guard to prevent cross-user presence overwrite 2026-05-14 13:28:47 +05:30
ArnabChatterjee20kandGitHub 7da3591d2b Merge branch '1.9.x' into presence-api 2026-05-14 13:12:42 +05:30
premtsd-codeandGitHub beb119883a Merge branch '1.9.x' into feat-platform-db-access 2026-05-14 08:37:33 +01:00
Prem Palanisamy 1da8d38daf Pin utopia-php/migration to 1.* (resolves 1.12.0) 2026-05-14 08:36:46 +01:00
ArnabChatterjee20k faf13c502b reordered set in the realtime 2026-05-14 12:46:58 +05:30
ArnabChatterjee20k 43da03ef63 removed redundant container set 2026-05-14 12:38:11 +05:30
Harsh MahajanandGitHub 05fb3a5d8d Merge pull request #12304 from appwrite/fix-specs-platform-order
Fix: project specs navigation order
2026-05-14 12:31:41 +05:30
ArnabChatterjee20k 5e4de3931a added presences leaf names 2026-05-14 12:17:45 +05:30
ArnabChatterjee20k 2b7ed2aa24 removed stale 2026-05-14 12:15:15 +05:30
harsh mahajan 5b7d6f1ca6 Fix project specs navigation order 2026-05-14 12:13:13 +05:30
ArnabChatterjee20k 5123956790 reverted schema 2026-05-14 11:57:23 +05:30
ArnabChatterjee20k 28d0348ef5 fixed case 2026-05-14 11:55:25 +05:30
ArnabChatterjee20k ad0b2ebb06 changed to sentence case for the dispatcher messages 2026-05-14 11:53:40 +05:30
ArnabChatterjee20k a3542ed7fa feat: enhance presence handling by refactoring usage and event triggering logic 2026-05-14 11:48:41 +05:30
ArnabChatterjee20k 0aa8d402ae feat: streamline presence cleanup logic and enhance stale presence deletion 2026-05-14 11:36:55 +05:30
Jake BarnbyandGitHub a4d1043684 Merge pull request #12302 from appwrite/fix-transaction-deletes
fix: match transactions collection name in deletes worker
2026-05-14 17:48:18 +12:00
Chirag AggarwalandGitHub 10de0d4245 Merge pull request #12284 from appwrite/codex/fail-open-abuse-timelimit
Fail open on abuse timelimit failures
2026-05-14 11:02:16 +05:30
Prem Palanisamy 0497137d80 Merge remote-tracking branch 'origin/1.9.x' into feat-platform-db-access
# Conflicts:
#	composer.lock
2026-05-14 06:20:03 +01:00
Prem Palanisamy f05b55add7 test: use project API key for platform migration E2E; bump migration lock 2026-05-14 06:15:55 +01:00
Chirag Aggarwal 1ced50db1f Merge remote-tracking branch 'origin/1.9.x' into codex/resolve-12284-conflict
# Conflicts:
#	app/controllers/shared/api.php
2026-05-14 10:43:05 +05:30
Damodar LohaniandGitHub 64b0cdaab7 Merge pull request #12303 from appwrite/chore/utopia-audit-2.3
chore: bump utopia-php/audit to 2.3.x
2026-05-14 10:19:46 +05:45
Damodar LohaniandGitHub bce5fe8e09 Merge branch '1.9.x' into chore/utopia-audit-2.3 2026-05-14 10:07:53 +05:45
Damodar LohaniandClaude Opus 4.7 6116d113b8 chore: refresh utopia-php/audit to 2.3.2
Picks up the opt-in async cleanup setter (utopia-php/audit#117)
needed for the deletes worker on the cloud side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 04:20:27 +00:00
Chirag AggarwalandGitHub 1fb1cec1b6 Merge pull request #12299 from appwrite/chore/refactor-function-queue-publisher
refactor: Migrate queueForFunctions to FunctionPublisher and FunctionMessage
2026-05-14 08:56:12 +05:30
Damodar LohaniandClaude Opus 4.7 fe5de129da chore: bump utopia-php/audit to 2.3.x
Brings in cursor pagination (cursorAfter/cursorBefore) and count(max)
support for the activities/audit endpoints. Pulls utopia-php/query 0.1.x
as a new transitive dependency. utopia-php/audit 2.3.x requires PHP 8.4,
satisfied by the appwrite/base:1.4.1 runtime image used on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:16:48 +00:00
Jake BarnbyandClaude Opus 4.7 10eae0ab87 fix: match transactions collection name in deletes worker
The DELETE_TYPE_TRANSACTION constant was 'transaction' (singular), so
the worker's inner switch on $document->getCollection() (which returns
'transactions') never matched and every transaction deletion fell to
the default branch, logging "No lazy delete operation available for
document of type: transactions". Renamed to DELETE_TYPE_TRANSACTIONS
to align with every other constant used in that switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:58:40 +12:00
Prem Palanisamy e9c16a8fd3 chore(deps): bump utopia-php/migration to feat-platform-db-access 2026-05-13 16:56:09 +01:00
Prem Palanisamy bddde5f298 Merge remote-tracking branch 'origin/1.9.x' into feat-platform-db-access
# Conflicts:
#	app/controllers/api/migrations.php
#	composer.json
#	composer.lock
#	src/Appwrite/Platform/Workers/Migrations.php
2026-05-13 16:36:39 +01:00
ArnabChatterjee20k 0466c82a42 fix: update error message for invalid permissions in PresenceRealtimeClientTest 2026-05-13 19:22:43 +05:30
ArnabChatterjee20k 995f844254 feat: refine permissions parameter handling in Presence action for clarity and consistency 2026-05-13 19:19:00 +05:30
ArnabChatterjee20k 18a26809af feat: improve user resolution during disconnect event for accurate actor context 2026-05-13 19:13:59 +05:30
ArnabChatterjee20k 42d3240d05 feat: refactor presence state usage and telemetry span naming for consistency 2026-05-13 19:12:11 +05:30
ArnabChatterjee20k 52c072489b Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-05-13 18:58:44 +05:30
ArnabChatterjee20k 602ceb55ef feat: refactor presence state usage across multiple files for consistency 2026-05-13 18:57:09 +05:30
ArnabChatterjee20k f09aec7651 feat: update presence handling and refactor related attributes for consistency 2026-05-13 18:52:14 +05:30
Chirag Aggarwal 85e2cf7d8b (refactor): Migrate queueForFunctions to FunctionPublisher and FunctionMessage 2026-05-13 18:28:05 +05:30
ArnabChatterjee20k f818f16ebe feat: simplify presence parameter validation by removing Nullable wrapper 2026-05-13 18:25:06 +05:30
Luke B. SilverandGitHub f6d2314e11 Merge pull request #12298 from appwrite/chore/normalize-span-keys
chore: normalize Span keys and centralize span lifecycle
2026-05-13 12:01:46 +01:00
loks0nandClaude Opus 4.7 24e108fdbf fix: use canonical project.id/user.id so trace sampler matches realtime and VCS spans
The sampler reads project.id from the span; realtime and VCS were storing it
under namespaced keys (realtime.project.id, vcs.github.event.repo.{id}.project.id),
causing all those spans to be dropped when _APP_TRACE_PROJECT_ID was set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 11:03:28 +01:00
loks0nandClaude Opus 4.7 9a016d7727 docs: tighten span tracing section in AGENTS.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:32:41 +01:00
Chirag Aggarwal d9987dd645 Fix rate limit headers when abuse check is skipped 2026-05-13 15:00:51 +05:30
loks0nandClaude Opus 4.7 a0c4b7695e chore: normalize Span keys and centralize span lifecycle
Span attribute keys are now snake_case with dots only for child
relationships. Worker span lifecycle moved to app/worker.php; selective
trace filtering moved to the exporter sampler in app/init/span.php so
handlers only call Span::add.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:29:42 +01:00
ArnabChatterjee20k 20162a69be feat: ensure unique ID assignment for new presence documents 2026-05-13 14:26:23 +05:30
Chirag AggarwalandGitHub 86df9ca14a Merge pull request #12297 from appwrite/chore/bump-utopia-cache-2
Bump Utopia cache to 2.1
2026-05-13 14:20:14 +05:30
ArnabChatterjee20k f1e0ef2991 feat: refactor queue handling for events and realtime to use coroutine context 2026-05-13 14:04:41 +05:30
Chirag Aggarwal 476e9de2a8 Bump Utopia cache to 2.1 2026-05-13 12:49:17 +05:30
ArnabChatterjee20k a1f321d92e updated presence model 2026-05-13 12:38:47 +05:30
ArnabChatterjee20k 4e2bd9c307 feat: add presence already exists error handling and update exception messages 2026-05-13 12:30:54 +05:30
ArnabChatterjee20k e799589d11 feat: implement deduplication for presence delete events and enhance presence management 2026-05-13 12:20:12 +05:30
ArnabChatterjee20k efadf1d74b removed redundant 2026-05-13 11:18:09 +05:30
ArnabChatterjee20k 7f6482a7d2 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-05-13 11:12:23 +05:30
ArnabChatterjee20k 84f18341ed feat: add setOwnerPermissions method and update presence ownership logic 2026-05-13 11:09:56 +05:30
Jake BarnbyandGitHub be2bb984c5 Merge pull request #12194 from appwrite/feat-insights-module
feat(insights): add insights module with CTA framework
2026-05-13 17:34:56 +12:00
Jake Barnby b058eb31f4 (refactor): inline CTAs as JSON on insights collection 2026-05-13 17:10:33 +12:00
Chirag AggarwalandGitHub 1c09d93a07 Merge pull request #12295 from appwrite/codex/update-sdk-generator-cover-image
Update SDK generator cover image config
2026-05-13 10:31:35 +05:30
Chirag Aggarwal 31fbc64ad2 Update SDK generator cover image config 2026-05-13 10:19:54 +05:30
Luke B. SilverandGitHub 35941ddc34 Merge pull request #12220 from appwrite/feat/utopia-http-resources-context
Migrate to utopia-php/http resources/context API
2026-05-12 23:01:29 +01:00
Jake BarnbyandClaude Opus 4.6 f4133609db (fix): remove invalid index prefix lengths exceeding column size
The prefix length 700 exceeded the resourceId/parentResourceId column
size of 255 (Database::LENGTH_KEY), crashing the container at startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 02:05:55 +12:00
Jake BarnbyandClaude Opus 4.6 b85ca1536a (fix): remove redundant null coalesce on always-defined variable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 01:53:33 +12:00
Jake BarnbyandClaude Opus 4.6 304b0dab35 (fix): address greptile P1 review comments
- Add skipFilters to Reports/Get.php (was the only endpoint still
  triggering the full N+1 subquery cascade)
- Scale CTA batch limit dynamically (insightCount * MAX_CTA_COUNT)
  instead of fixed APP_LIMIT_SUBQUERY to prevent silent truncation
- Revert deleteReport to callback-based pagination so CTAs are not
  orphaned when a report has more than APP_LIMIT_SUBQUERY insights
- Add explicit prefix lengths (700) to _key_project_resource and
  _key_project_parent_resource indexes to stay under InnoDB 3072-byte limit
- Validate CTA service/method against ADVISOR_CTA_SERVICES and
  ADVISOR_CTA_METHODS enums in the CTAs validator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 01:42:59 +12:00
Jake BarnbyandClaude Opus 4.6 fdb8e06f12 (test): fix misleading advisor e2e test names and assertions
- Rename testReportsAreReadOnly → testReportsCreateAndUpdateNotExposed
  (reports ARE deletable, they're not read-only)
- Extract testDeleteReportMissing with proper error type assertion
- Rename testInsightsAreReadOnly → testInsightsCreateUpdateDeleteNotExposed
- Fix insight create test to use correct URL (/reports/:id/insights)
  instead of nonexistent /manager/ prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 01:15:43 +12:00
Jake BarnbyandClaude Opus 4.6 9da4a3260d (refactor): improve advisor module perf, security, and maintainability
- Fix N+1 in Reports/XList (51→4 queries) via skipFilters + batch fetch
- Add skipFilters to Reports/Delete and cursor fetch (avoid loading all
  nested insights/CTAs just for ownership check)
- Fix N+1 in deleteReport worker (flat CTA deletion instead of per-insight)
- Add advisor entity cleanup on project deletion (reports, insights, CTAs)
- Remove resourceInternalId, parentResourceInternalId, $permissions from
  Insight response model (internal IDs leak DB internals, permissions unused)
- Remove dead subQueryInsightCTAs filter registration
- Remove stale enum-value comments from platform schema
- Fix _key_dismissedAt index to include projectInternalId
- Fix scope category from 'Other' to 'Advisor'
- Switch action base class from Utopia\Platform\Action to Appwrite\Platform\Action

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 01:13:37 +12:00
ArnabChatterjee20k 5827f2ece6 fix: correct presence usage decrement in triggerPresenceUsage calls 2026-05-12 18:05:51 +05:30
ArnabChatterjee20k 62de39b858 removed todo comment 2026-05-12 17:44:28 +05:30
ArnabChatterjee20k c1cab2e41a removed redundant 2026-05-12 17:33:21 +05:30
Jake Barnby f9ff1166b9 (perf): batch-fetch CTAs across an insight set to collapse N+1 2026-05-12 23:24:26 +12:00
Chirag Aggarwal 1ca8282944 Remove unused request logError resource 2026-05-12 16:30:31 +05:30
Jake BarnbyandGitHub 4cd35e4d93 Merge branch '1.9.x' into feat-insights-module 2026-05-12 22:59:34 +12:00
loks0nandClaude Opus 4.7 e97dfb041a Bump utopia-php/http to 2.0-rc1 and utopia-php/platform to 1.0-rc1
These RC releases ship the new resources/context API that this PR
adopts. Drop the dev-branch alias on utopia-php/http now that 2.0.0-rc1
is tagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:55:41 +01:00
Chirag Aggarwal e132784532 Fix cache auth role lookup after rate limit refactor 2026-05-12 16:24:50 +05:30
loks0nandClaude Opus 4.7 1cef07a2d4 Fix GraphQL coroutine context propagation
Promises\Swoole was already propagating the request container into child
coroutines, but using its own pre-existing key (__utopia_http_request_container)
rather than the new utopia-php/http key (__utopia__). With the keys
mismatched, the propagation was dead code and resolvers spawned from
webonyx coroutines saw an empty context, failing every GraphQL query
with 'Dependency utopia:graphql not found'.

Align the key so child coroutines actually inherit the request's
container (wrapped as a child Container, so request-scoped overrides
in the resolver can't bleed back into the outer request).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:47:52 +01:00
loks0nandClaude Opus 4.7 93e96a044d Fix bus resolver to look up context container per dispatch
The resources/context migration replaced the resolver closure with a
first-class callable from `$swoole->context()->get(...)`. That
captures the result of `$swoole->context()` once -- on the first
dispatch -- and binds the bus to the first request's per-request
container forever after.

Wrap the lookup in a fresh closure so each dispatch re-resolves
`context()` against the current Swoole coroutine. Restores the
behavior of the pre-migration `getContainer()` form.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:47:52 +01:00
loks0nandClaude Opus 4.7 040ce6e335 Simplify Swoole worker bootstrap
EVENT_START and EVENT_TASK were each constructing `new Http($swoole, 'UTC')`
purely as a vehicle to reach DI -- never configuring routing, never calling
->run(). Now that the Swoole adapter exposes resources() directly, the
Http instance is no longer needed: the resources container we passed into
the Server constructor is already in scope as $container.

Drop the throwaway Http construction, pass $container straight through, and
update createDatabase() to take Container instead of Http to match. Also
rename $swooleAdapter to $swoole for consistency with the rest of the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:47:51 +01:00
Chirag Aggarwal a51fd49ed1 Refactor abuse rate limiting init hook 2026-05-12 16:17:23 +05:30
loks0nandClaude Opus 4.7 4bdcdb6f96 Migrate to utopia-php/http resources/context API
Adopts the new split DI containers in utopia-php/http: `resources()` for
boot-time wiring (shared across requests) and `context()` for per-request
state. Replaces the removed `getResource()`/`setResource()`/`getContainer()`
helpers throughout the HTTP entry point, controllers, GraphQL layer, and
installer.

Bumps the dependency chain accordingly: utopia-php/http to the dev branch
(aliased to 0.34.25 to satisfy platform's exact pin), servers 0.4.*,
queue 0.18.*, and pulled-along cli/platform/database upgrades.

Also tightens app/init/resources/request.php by collapsing single-return
factories to arrow functions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:41:42 +01:00
Chirag Aggarwal 7914fde9f1 Use error_log for abuse timelimit failures 2026-05-12 16:09:20 +05:30
ArnabChatterjee20k 95dd44115d refactor: update Dockerfile to remove composer stage and adjust presence handling in tests 2026-05-12 15:49:59 +05:30
Jake Barnby 45f3a64617 (perf): skip subquery filters when loading report for ownership-only check 2026-05-12 22:09:50 +12:00
63df500b6f refactor(constants): remove Advisor alias prefixes for insight/report enums
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/55c56d91-4db4-4501-b5cb-42159dbc9778

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-12 08:09:18 +00:00
Chirag Aggarwal d523c7425a Log abuse timelimit fail-open errors 2026-05-12 13:31:33 +05:30
Chirag Aggarwal 728298d2f5 Address abuse timelimit review comments 2026-05-12 13:23:39 +05:30
Chirag Aggarwal ac79a91aa9 Fail open on abuse timelimit failures 2026-05-12 13:12:13 +05:30
Jake Barnby 51c65093b8 (feat): add appId and appInternalId to reports collection for third-party app submissions 2026-05-12 16:09:38 +12:00
harsh mahajan dda38442ad refactor: simplify deployment skip patterns 2026-05-11 18:56:31 +05:30
harsh mahajan cc0207cfe4 fix: align appwrite skip directives 2026-05-11 18:32:14 +05:30
harsh mahajan fa63983fd7 fix: bracket appwrite skip directives 2026-05-11 18:11:48 +05:30
harsh mahajan 9a6d2a6d21 refactor: use fixed commit skip directives 2026-05-11 18:02:51 +05:30
Jake Barnby c18865423e Merge remote-tracking branch 'origin/1.9.x' into feat-insights-module
# Conflicts:
#	composer.lock
#	src/Appwrite/Platform/Workers/Migrations.php
2026-05-12 00:23:17 +12:00
Jake Barnby fec779de05 (chore): bump utopia-php/migration to 1.10.2 to match resolver signature 2026-05-12 00:15:49 +12:00
harsh mahajan 73eb14d4bb fix: fail open for non-string commit messages 2026-05-11 17:19:55 +05:30
harsh mahajan 4684688fc7 feat: expose commit skip patterns 2026-05-11 17:17:01 +05:30
Jake BarnbyandClaude Opus 4.7 53d18a0d08 refactor(advisor): rename module to Advisor (service-named, top-level)
The module's namespace and directory now match the top-level service
name (`advisor`) instead of one of its resource names (`insights`):

- src/Appwrite/Platform/Modules/Insights -> .../Modules/Advisor
- src/Appwrite/Insights -> src/Appwrite/Advisor
- tests/unit/Insights -> tests/unit/Advisor
- Route group label flipped from `'insights'` to `'advisor'`
- Section-header comments aligned

Resource names (`insights`, `reports`, `insightCTAs`) and the
`Insight*`/`Report` response models stay — those are the resources the
service exposes, not the service itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:40:06 +12:00
Harsh MahajanandGitHub 68fdf3fbb4 Merge branch '1.9.x' into feat/skip-deployment-commit-patterns 2026-05-11 17:07:55 +05:30
harsh mahajanandClaude Sonnet 4.6 444d4b4e66 refactor: standalone regex matching for commit skip patterns
Replace directive-extraction approach with word-boundary regex matching
so plain-word patterns like "skip appwrite" and "appwrite skip" work
alongside bracket directives. Use \s+ between word tokens (required
space) and \s* only after ":" tokens (git trailer flexibility).
Add tests for "skip appwrite" and "appwrite skip" with case insensitivity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:04:58 +05:30
harsh mahajan 4a4f51622d fix: tighten commit skip directive matching 2026-05-11 17:04:58 +05:30
harsh mahajanandClaude Sonnet 4.6 2836f89b34 feat: skip deployment on commit message pattern match
Add providerCommitSkipPatterns array field to functions and sites.
Any commit message containing one of the patterns (case-insensitive
substring) skips the VCS-triggered deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:04:58 +05:30
ArnabChatterjee20kandCopilot cad35c7d5c refactor: enhance presence deletion logic and add expiration check in Get action
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 16:47:36 +05:30
Jake BarnbyandClaude Opus 4.7 e8bdb954f2 docs(advisor): add per-endpoint reference docs
Each SDK method now points at its own markdown file under
docs/references/advisor/ instead of an inline heredoc, matching the
house convention used by every other module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:51:58 +12:00
ArnabChatterjee20k 9f65c6ce41 updated namespace 2026-05-11 15:27:54 +05:30
Jake BarnbyandClaude Opus 4.7 7041c1d719 fix(migrations): resolve destination DSN per database type
utopia-php/migration 1.10.2 stopped propagating the source DSN to the
destination's `_databases.database` and added an opt-in `getDatabaseDSN`
resolver. Without it, the destination DB row gets a blank `database`
field, so reads on documentsdb/vectorsdb fall back to the project's
main DSN (mongodb in dedicated mode) and fail with "Vector types are
not supported by the current database".

Pass a resolver that builds the proper DSN from the destination
project's pools per database type, mirroring the logic
`Database/Create.php` already uses on first creation. Made
`constructDatabaseDSNFromProjectDatabase` public+static so the worker
can reuse it instead of duplicating the pool-selection logic.

This is a 1.9.x regression introduced by the migration 1.10.1 -> 1.10.2
bump; affects every PR that runs MongoDB-dedicated Migrations tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:25:53 +12:00
ArnabChatterjee20k 28621678dd changed presence to presences for the e2e tests 2026-05-11 14:35:01 +05:30
ArnabChatterjee20k 4b98579adf refactor: simplify user retrieval in Presence handler by directly instantiating User object 2026-05-11 14:29:47 +05:30
Jake BarnbyandClaude Opus 4.7 db4cb5361d fix(databases): fall back to project DSN when database attr is empty
`getDatabasesDB` used `??` to fall back from a database doc's `database`
attribute to the project DSN, but `??` only triggers on null. Migration
destinations end up with an empty-string `database` (the value is copied
from the source DB but isn't a valid DSN on the destination's pool),
which slipped past the fallback and surfaced as a 500 with
`new DSN('mysql://')` in the catch block. Use elvis so empty strings
fall back too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:12:48 +12:00
Jake BarnbyandClaude Opus 4.7 ad02ad3f81 fix(advisor): fall back to direct insight lookup beyond subquery cap
The report's embedded insights subquery is capped at APP_LIMIT_SUBQUERY,
so insights past position 1000 would 404 even though they exist. Try
the embedded slice first, then fall back to a scoped direct lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:23:20 +12:00
735be3e892 fix(advisor): align nested insights list SDK method naming
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/7e091450-e0b2-4c2c-98a7-ba964e5436ca

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-11 03:25:41 +00:00
49bf1198b2 refactor(advisor): polish constants aliases and insight lookup helper
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/517a4586-d3e8-40b3-a3a9-f2d2ca82b0a0

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-11 02:08:29 +00:00
9186ce3f24 chore(advisor): document null index prefix lengths in reports index
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/517a4586-d3e8-40b3-a3a9-f2d2ca82b0a0

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-11 02:05:20 +00:00
bfd6cebbb0 fix(advisor): address review comments on tests, naming, docs, and get insight
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/517a4586-d3e8-40b3-a3a9-f2d2ca82b0a0

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-11 01:59:31 +00:00
Jake BarnbyandClaude Opus 4.7 8fa9ead279 chore: merge 1.9.x into feat-insights-module
Resolves conflict in app/init/constants.php (kept 4327 cache buster, took 1.9.4 stable version).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:59:01 +12:00
Jake BarnbyandClaude Opus 4.7 83c0a32494 fix(advisor): make reports.target a TEXT column
target stores free-form URLs or resource IDs. URLs in the wild can
exceed the prior 2048-char cap, so switch the column to VAR_TEXT
(65535). The _key_project_target index already declares an explicit
700-char prefix length, so indexing still works on both MariaDB and
MongoDB.

Bump APP_CACHE_BUSTER for the schema change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:42:29 +12:00
Jake BarnbyandClaude Opus 4.7 b830c08257 fix(advisor): use VAR_TEXT for free-form fields
Switch markdown summaries, JSON CTA params, and the embedded
insights/ctas subQuery payloads from sized VAR_STRING to VAR_TEXT.
None of these fields are indexed and they all hold free-form data, so
removing the artificial size cap means we never have to revisit the
limit when an analyzer surfaces a longer report.

- reports.summary, insights.summary: markdown, no upper bound enforced
- reports.insights, insights.ctas: subQuery virtuals carrying full
  child rows
- insightCTAs.params: JSON parameter blob for arbitrary CTA actions

Bump APP_CACHE_BUSTER for the schema change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:30:45 +12:00
Jake BarnbyandClaude Opus 4.7 b747c97561 fix(advisor): address review comments on scopes and schema
- Register dedicated reports.write scope and switch deleteReport to it
  so cloud can issue narrowly-scoped delete keys without granting
  insights.write.
- Make insights.parentResourceInternalId optional with null default to
  match its companion parentResourceType/parentResourceId fields and
  unblock insights with no parent (e.g. database-level performance
  insights).
- Tighten Insight.reportId model description: insights always belong to
  a report, ad-hoc insights are not supported.
- Add reports.write to default test API key and admin role so existing
  e2e tests using serverHeaders() can hit the delete endpoint.
- Bump APP_CACHE_BUSTER for the schema change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:18:39 +12:00
Jake BarnbyGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
e27997dcbc Update src/Appwrite/Insights/Validator/CTAs.php
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-09 12:06:09 +12:00
6b64eb85b3 fix(advisor): require null-default VAR_ID schema fields
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/38cebef5-ed45-4283-9097-d48dd0fafb06

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-09 00:00:28 +00:00
Jake BarnbyandClaude Opus 4.7 5ba92c7ff4 test(proxy): clean up rules before deleting their site
testCreateRedirectRule reassigned $ruleId for the second redirect rule,
so only the second was tracked, and cleanupSite was called before
cleanupRule. Site deletion cascades to its rules, so cleanupRule then
saw a 404 and the strict assertEquals(204) blew up.

Track both rule IDs and tear them down before the site so the asserted
204 actually fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:09:48 +12:00
Jake BarnbyandClaude Opus 4.7 a23555783f test(advisor): initialise project before getNewKey
testReadWithAdvisorScopes called getNewKey() as its first action, but
getNewKey reads self::$project['$id'] without triggering project
creation. ParaTest runs each test method in a fresh worker, so
self::$project is empty until getProject() is called. The empty id
produced /v1/projects//keys, which doesn't match any route and 404s.

Call getProject() first, like every other consumer of getNewKey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:49:22 +12:00
ArnabChatterjee20k 6b7fb918cc refactor: streamline presence upsert logic and remove redundant presenceId parameter 2026-05-08 19:09:50 +05:30
Jake BarnbyandClaude Opus 4.7 83cf47e3fd fix(advisor): correct index lengths to fit MongoDB 1024 limit
The reports `_key_project_target` index was failing to create on MongoDB
because target's full size (2048) plus targetType (64) exceeded the 1024
character maximum. Set explicit lengths to truncate target to 700 chars
(total 765, under both Mongo 1024 and MariaDB 768 limits).

Also drop redundant explicit lengths from VAR_ID positions in the
insights and insightCTAs indexes — the validator rejects explicit
lengths on VAR_ID attributes, and they default to length 1 anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:29:01 +12:00
ArnabChatterjee20k ffeb27e5a3 updated presence test 2026-05-08 14:36:59 +05:30
ArnabChatterjee20k 46e86ce854 fix: drop expired presences at fetch time and clean up Presence model attributes 2026-05-08 12:28:33 +05:30
721b3c1229 style(advisor): add strict return types to report delete action
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/6496395d-5cbf-42ba-a3e0-d3c9e6ca901c

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 06:56:05 +00:00
f63ded6159 chore(advisor): simplify report cleanup delete queries
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/6496395d-5cbf-42ba-a3e0-d3c9e6ca901c

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 06:55:28 +00:00
56b9de097f fix(advisor): restore report delete with async nested cleanup
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/6496395d-5cbf-42ba-a3e0-d3c9e6ca901c

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 06:54:49 +00:00
ArnabChatterjee20k 5767ab8ac7 refactor: update triggerPresenceUsage to accept Document and streamline presence cleanup logic 2026-05-08 12:09:12 +05:30
ArnabChatterjee20k 5002afde09 optimizations
fix: update presence handling to include hostname and correct usage trigger parameter
improvement: removed an external findOne call to the db and used the timestamps for knowing upsert did an update or a create
2026-05-08 12:05:03 +05:30
a9902c33df chore(advisor): clarify insights.write scope usage
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/8d7897b5-ac68-487d-954a-be717380bf66

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 06:08:17 +00:00
6d0eab2583 refactor(advisor): make insights API read-only in CE
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/8d7897b5-ac68-487d-954a-be717380bf66

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 06:07:23 +00:00
ArnabChatterjee20k c13a0d1010 updated test 2026-05-08 11:27:11 +05:30
0829b26508 refactor(insights): remove redundant payload field
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/a680e208-34b8-4bae-a7fd-51949112233a

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 05:44:20 +00:00
ArnabChatterjee20k f526fc8577 roles updated 2026-05-08 11:14:00 +05:30
ArnabChatterjee20k 63333a0cbf updated delete endpoint exceptions 2026-05-08 11:13:38 +05:30
ArnabChatterjee20k 0048d3f911 updated presence worker deletion to cap the 30 days limit 2026-05-08 11:10:36 +05:30
ArnabChatterjee20k 31adbe6189 fix: update presence permissions from read to write in tests and implementation 2026-05-08 11:02:25 +05:30
ArnabChatterjee20k a1811570b3 fix: update presence permissions from read to write in roles and HTTP handlers 2026-05-08 10:49:38 +05:30
8c57ff161e fix(insights): use VAR_ID for project internal references
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/688345d8-e680-46c7-9002-82f73193461b

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 05:17:15 +00:00
0d65ffbb6c chore(insights): simplify report index lengths and orders
Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/2e52811c-bf98-4b39-b3f7-64fabcef4cf6

Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com>
2026-05-08 05:13:38 +00:00
harsh mahajanandClaude Sonnet 4.6 90f5f9b30b refactor: standalone regex matching for commit skip patterns
Replace directive-extraction approach with word-boundary regex matching
so plain-word patterns like "skip appwrite" and "appwrite skip" work
alongside bracket directives. Use \s+ between word tokens (required
space) and \s* only after ":" tokens (git trailer flexibility).
Add tests for "skip appwrite" and "appwrite skip" with case insensitivity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:25:39 +05:30
Harsh MahajanandGitHub 408a12322a Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-05-07 18:17:27 +05:30
Jake Barnby 97477686fd chore: sync 1.9.x into feat-dedicated-db 2026-05-08 00:29:51 +12:00
Jake BarnbyandClaude Opus 4.7 9de9e5bdf0 test: stabilise ProjectWebhooks and Proxy e2e suites
ProjectWebhooks: bump assertEventually timeouts from 15s to 30/60s. Under
ParaTest with 4 parallel processes the column/attribute worker can take
longer than the original 15s budget to flip status to 'available', which
manifested as a flaky 'available' vs 'processing' assertion in
testUpdateRow on retry. The 60s budget covers attribute and column
provisioning; the 30s budget covers webhook delivery and auto-disable
polling. The function deployment build keeps its existing 120s budget.

Proxy: testCreateRedirectRule was deleting the site before its rules and
only tracking one of two created rule IDs, so cleanupRule hit a 404 because
the site cascade had already removed the rule. Track both rule IDs
distinctly (301 and 307) and clean rules up before the site so cleanup is
deterministic regardless of cascade behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:37:15 +12:00
Jake BarnbyandClaude Opus 4.7 de361573f2 fix: thread gauges through StatsResources message envelope
PHPStan flagged $payload as undefined in StatsResources::action — the gauges
fast path was reading from a variable that never existed, so the optimization
silently no-op'd and always fell through to the full count. Add a typed
$gauges field to the StatsResources message DTO so the worker reads the
pre-computed metrics from the parsed envelope, and drop redundant null
coalescing on the array{metric, value} shape since PHPStan now proves the
keys exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:30:13 +12:00
Jake Barnby 917ce627bb chore: merge 1.9.x into feat-dedicated-db 2026-05-07 20:30:08 +12:00
Jake BarnbyandGitHub a8f33ab964 Merge branch '1.9.x' into presence-api 2026-05-07 19:13:57 +12:00
Jake BarnbyandGitHub 0f67fd7618 Merge pull request #12210 from appwrite/realtime-messsage-dispatchers
Realtime messsage dispatchers (presence api)
2026-05-07 19:12:56 +12:00
ArnabChatterjee20k ec8d48327c reverted type error as query exception only thown 2026-05-07 12:34:34 +05:30
ArnabChatterjee20k ce07c6ffd5 added type error to the subscribe handler 2026-05-07 12:24:42 +05:30
ArnabChatterjee20k 6679d02a35 refactor: rename connection to connectionId for consistency across handlers 2026-05-07 11:48:45 +05:30
ArnabChatterjee20kandCopilot 63e226c761 updated
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 14:50:40 +05:30
ArnabChatterjee20k 6290a664e9 refactor: rename presence methods for consistency and clarity 2026-05-06 14:16:59 +05:30
Jake Barnby 6c6782dd28 Merge remote-tracking branch 'origin/1.9.x' into pr-12194-feat-insights-module
# Conflicts:
#	app/config/scopes/project.php
2026-05-06 20:33:40 +12:00
Jake BarnbyandClaude Opus 4.7 4660185a23 test(insights): fix CI failures from nesting refactor
Three breakages on the prior CI run:

1. PHPUnit 12 didn't propagate `@depends` data — chained tests got
   ArgumentCountError because they expected `array $data` but PHPUnit
   passed nothing. Convert all annotations to `#[Depends]` attributes,
   matching the modern style already used by the Migrations and
   VectorsDB suites.

2. `InsightsCustomConsoleTest` extends ProjectConsole, which doesn't
   set up a project API key. The trait's `serverHeaders()` hardcodes
   `x-appwrite-key`, so every Console test 401'd. Drop the Console
   class entirely — the manager Create endpoint is KEY-only by
   design, the Server class already exercises every code path, and a
   Console-side variant adds no real coverage.

3. `testCreateRequiresManagerScope` called `getNewKey()`, which lives
   on `ProjectCustom`. PHPStan flagged the call as undefined when the
   trait was analyzed against the (no-longer-existing) Console class.
   Move the test into `InsightsCustomServerTest.php` directly so it's
   only ever resolved against `ProjectCustom`.

Plus PHP 8.4 + match-exhaustive cleanups PHPStan caught while I was
in there:

- `?array $headers = null` instead of `array $headers = null` on every
  helper (PHP 8.4 deprecates implicit-nullable params).
- `?string $insightId = null` on `sampleInsight()`.
- `parentResourceType` match collapsed to `tablesDB => 'tables';
  databases/documentsDB/vectorsDB => 'collections'`. The earlier
  `$type` match already throws on unknown engine, so the fall-through
  default was unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:20:54 +12:00
ArnabChatterjee20k ac80ba270b updated queries 2026-05-06 13:24:34 +05:30
ArnabChatterjee20k f9c18a6462 updated validator subscribe 2026-05-06 13:20:19 +05:30
ArnabChatterjee20k d2ea6ef2ca removed redundant dumps 2026-05-06 12:33:42 +05:30
Jake BarnbyandClaude Opus 4.7 68c354e09b refactor(insights): nest insights API under reports
Insights are children of reports — make the URL hierarchy reflect that.

Endpoints:
- POST   /v1/manager/reports/:reportId/insights        (manager Create)
- GET    /v1/reports/:reportId/insights                (List)
- GET    /v1/reports/:reportId/insights/:insightId     (Get)
- PATCH  /v1/reports/:reportId/insights/:insightId     (Update)
- DELETE /v1/reports/:reportId/insights/:insightId     (Delete)

`reportId` moves from optional body field to required path param. All
endpoints fetch the report first (404 REPORT_NOT_FOUND if missing or
in another project), then verify the insight's `reportInternalId`
matches before doing anything else.

Side effects:
- Event names nested: `reports.[reportId].insights.[insightId].create`
  etc. Top-level `insights.*` event tree removed from events.php.
- Realtime channel parser handles the nested form: a `reports.{rid}`
  event lights up `reports`, `reports.{rid}` channels; a nested
  `reports.{rid}.insights.{iid}` event also lights up
  `reports.{rid}.insights` and `reports.{rid}.insights.{iid}`.
- Audit resource paths nested similarly:
  `report/{request.reportId}/insight/{response.$id}`.
- listInsights query validator drops `reportId` from
  ALLOWED_ATTRIBUTES — it's path-scoped now, not a query filter.

Tests:
- E2E helpers `createInsight`/`getInsight`/`listInsights`/
  `updateInsight`/`deleteInsight` all take `reportId` as the first
  argument.
- New `createFixtureReport()` helper for standalone validation tests
  that need a parent.
- Dropped `testCreateWithoutReport` — reportId is mandatory now.
- `testCreateRejectsUnknownReport` now exercises the path-level 404
  rather than a body-level check.
- `testGet` and `testUpdateMissing` exercise the
  wrong-reportId-but-valid-insightId case (returns
  `report_not_found`).
- `testList` asserts every result carries the path's reportId, plus a
  404 case for a nonexistent parent.
- `testCreateForEachEngine` and the standalone create-rejection tests
  inline-create their own fixture report and clean up after.
- `testListSurvivesEmptyDatabase` renamed to
  `testListSurvivesEmptyReport` and uses a fresh fixture report.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:53:54 +12:00
ArnabChatterjee20k 1946ee86b2 feat: add realtime message handlers for authentication, ping, presence, subscribe, and unsubscribe 2026-05-06 12:20:06 +05:30
ArnabChatterjee20k 5eabeeea20 refactor: update payload validators to use closures for instantiation and improve error handling 2026-05-06 12:10:50 +05:30
Jake BarnbyandClaude Opus 4.7 1f4111d6f2 test(insights): drop summary update from testUpdate
User Update only accepts severity + status now — analyzer-controlled
fields like summary flow through the manager Create endpoint. The
previous testUpdate sent `summary: 'Updated summary.'` and asserted
it changed; Utopia silently dropped the unknown param so the
assertion would have failed.

Trim the call to severity-only and verify the analyzer fields
(including summary) are preserved on the response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:34:04 +12:00
Jake BarnbyandClaude Opus 4.7 0b72dba817 refactor(insights): drop CTA key field
`key` was a leftover from when CTAs were embedded JSON — there's no
remaining reason to require analyzers to invent a within-insight
identifier. The execution layer is gone (no `cta.key` event format),
insights are immutable from the user side (analyzers re-ingest by
delete + recreate, so idempotent matching never happens), and `label`
already covers human-facing identification. The console can group/sort
CTAs by `service`+`method` if needed.

- Schema: drop `key` attribute and the UNIQUE
  `(insightInternalId, key)` index from insightCTAs. Required fields
  are now `label`, `service`, `method` (+ optional `params`).
- Validator no longer requires `key`. Drop the dup-key normalization
  loop in the manager Create endpoint — there's no semantic
  uniqueness to enforce.
- Response model: `InsightCTA` keeps `$id` + standard headers,
  `insightId` backref, and the four functional fields.
- E2E: drop sampleCTA's `$key` parameter, drop the
  testCreateRejectsDuplicateCTAIds test entirely, rename empty-fields
  test to testCreateRejectsCTAWithEmptyLabel and update the missing-
  fields tests to drop `key` from their payloads.
- Unit tests rewritten to drop `key`.
- Comment on the `insights.ctas` virtual attribute updated to
  reference the renamed `insightCTAs` collection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:29:40 +12:00
Jake BarnbyandClaude Opus 4.7 c5dfc42a60 refactor(insights): rename ctas collection to insightCTAs
Disambiguate the platform-level collection name. Field/request-param
remains `ctas` (the embedded array on the insight response).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:12:17 +12:00
Jake BarnbyandClaude Opus 4.7 5404bfec75 refactor(insights): promote CTAs to own collection with backref
Embedding CTAs as a 16384-byte JSON blob on `insights` was the wrong
shape — they're real documents with their own lifecycle. Move them out.

Schema:
- New platform `ctas` collection. Each row carries `projectInternalId`,
  `projectId`, `insightInternalId`, `insightId` (backref), plus the
  CTA fields: `key`, `label`, `service`, `method`, `params`.
- Indexes: `(projectInternalId, insightInternalId)` for the subquery
  lookup and a UNIQUE `(insightInternalId, key)` so the per-insight
  uniqueness invariant lives at the DB layer (not just in PHP).
- The `ctas` field on `insights` becomes a virtual attribute backed by
  a new `subQueryInsightCTAs` filter that joins child docs at read
  time. Consumers still get CTAs embedded on the insight response —
  one round-trip from their perspective.
- The CTA descriptor's within-insight identifier renamed `id` → `key`
  (clashed with the document `$id`). Validator updated.

Endpoints:
- Manager Create now persists CTAs as separate `ctas` documents after
  the parent insight, then re-fetches the insight so the response
  carries the freshly-joined CTA list.
- User Update trimmed to user-controlled state only (`severity`,
  `status`). `title`, `summary`, `payload`, `ctas`, and `analyzedAt`
  are analyzer-controlled — analyzers re-ingest by deleting and
  POSTing again to the manager endpoint.
- Insight Delete cascades to CTAs.
- Report Delete cascades through Insights → CTAs.

Response model:
- InsightCTA gains the standard document headers (`$id`,
  `$createdAt`, `$updatedAt`) and an `insightId` backref. The
  caller-supplied identifier is now `key`.

Tests:
- E2E sampleCTA factory uses `key` everywhere; testCreate asserts the
  freshly-created CTA carries `$id`, `$createdAt`, `insightId`, and
  the right shape.
- Dropped the testUpdate*CTA* tests — user Update no longer accepts
  CTAs. testDismissViaUpdate now depends on testUpdate directly.
- Unit tests rewritten to validate `key` instead of `id`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:07:49 +12:00
Jake BarnbyandClaude Opus 4.7 38efdf18e2 feat(insights): add parent resource pointer
Eldad's review comment: insights about nested resources need a pointer
to the containing parent (the file-in-bucket pattern). Add three
optional fields:

- parentResourceType (plural noun, e.g. `tables`, `collections`)
- parentResourceId
- parentResourceInternalId

so an insight whose `resourceType=indexes` / `resourceId=_idx_status`
can also carry `parentResourceType=tables` / `parentResourceId=orders`
to identify the table that owns the index. All three are nullable for
top-level resources (e.g. a project-wide audit finding).

Schema, response model, manager Create endpoint, and the listInsights
query validator (parent fields are filterable). New compound index
`_key_project_parent_resource(projectInternalId, parentResourceType,
parentResourceId, $sequence)` to support the parent lookup pattern
the console will use ("show all insights for table X").

E2E factory generates a parent by default (engine-aware:
tables for tablesDB, collections for the others). New
testCreateWithoutParentResource exercises the top-level case;
testList gains a parent-resource filter assertion; testUpdate's
preserved-fields check picks up the new attributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:25:34 +12:00
Jake BarnbyandClaude Opus 4.7 4fc3e9c386 refactor(insights): manager-only Create endpoint + native categories array
Insights are produced by internal Appwrite services (edge, executor,
background analyzers) — never by user clients. Move the ingestion
endpoint accordingly.

- Move Http/Insights/Create.php → Http/Manager/Insights/Create.php.
- Path: /v1/insights → /v1/manager/insights. SDK Method marked
  `hide: true` and namespaced under `manager` so generated SDKs don't
  expose it. Auth narrowed from [ADMIN, KEY] to [KEY] only.
- New scope `insights.manager`. Not granted by any user role
  (app/config/roles.php) — Cloud/edge teams configure their internal
  key issuance to grant it. `insights.write` description trimmed to
  the user-facing surface (update/dismiss/delete) since create is now
  manager-only.
- Reports, ListInsights, GetInsight, UpdateInsight, DeleteInsight
  remain at /v1/insights/*. Existing scopes unchanged.
- Reports `categories` switched from JSON-encoded string to a native
  array<string> column (size 64 per entry, up to 32 entries via the
  endpoint validator). MySQL JSON-array indexes are weak and we never
  query individual entries — read+rewrite only.
- E2E test API key in tests/e2e/Scopes/ProjectCustom.php gains
  insights.read/write/manager + reports.read/write so the manager
  endpoint is reachable from the test harness.
- E2E InsightsBase.createInsight() helper now POSTs /manager/insights.
- New testCreateRequiresManagerScope verifies a key with
  insights.read/write but no insights.manager is rejected with 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:20:49 +12:00
Jake BarnbyandClaude Opus 4.7 a1f64c6f71 refactor(insights): split CTA action into service + method
- InsightCTA model now exposes `service` (SDK namespace) and `method`
  (function name) instead of a single `action` string. Drops the
  string-splitting burden on every consumer and lets the console reach
  the right SDK method directly.

- Validator requires both `service` and `method` non-empty; same
  16-entry max still enforced.

- Endpoint normalization (Create + Update) splits the new shape into
  the persisted CTA descriptor.

- Constants split: INSIGHT_CTA_SERVICE_* (databases / tablesDB /
  documentsDB / vectorsDB) and INSIGHT_CTA_METHOD_* (createIndex).

- Insight model + InsightCTA model docs updated with the new field
  semantics and per-engine examples.

- E2E factory `sampleCTA($id, $engine)` emits the correct service and
  engine-appropriate params keys (tableId/columns for tablesDB;
  collectionId/attributes everywhere else). Engine matrix asserts
  `service` and `method` independently.

- Added e2e + unit coverage for the new failure modes (missing
  service, missing method, empty service, empty method).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:35:33 +12:00
Jake BarnbyandClaude Opus 4.7 8f79379b6e test(insights): full e2e + per-engine CTA action mapping
- Restructure InsightsBase trait with explicit helper methods
  (createInsight/getInsight/listInsights/updateInsight/deleteInsight,
  createReport/getReport/listReports/updateReport/deleteReport, plus
  sampleInsight/sampleCTA factories) — same shape ProxyBase uses.

- Add coverage for: report CRUD + duplicate-id rejection, invalid type
  rejection, list filtering by all allowed attributes, cursor
  pagination + missing-cursor, update preserving untouched fields,
  CTA validation edge cases (duplicate ids, empty fields, count > 16),
  dismissal round-trip + status filter, report cascade delete,
  unauthorized access (no server key), empty-result list.

- Engine-specific insight types (tablesDBIndex, documentsDBIndex,
  vectorsDBIndex, plus the legacy databaseIndex) so the CTA's `action`
  can map to the matching public API: databases.createIndex,
  tablesDB.createIndex, documentsDB.createIndex,
  vectorsDB.createIndex. dataProvider drives the engine matrix and
  asserts the right action lands in the persisted CTA. Constants for
  each action name live in app/init/constants.php.

- InsightCTA model docs spell out which action belongs to which engine
  and that the params keys differ between APIs (tableId/columns for
  tablesDB vs collectionId/attributes for the legacy / DocumentsDB /
  VectorsDB APIs).

- Insight model `type` description now lists every engine variant.

- CTAsTest gains coverage for object-shaped params, empty-action and
  empty-label rejection, and the default 16-entry cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:25:49 +12:00
Jake BarnbyandClaude Opus 4.7 00565ea471 refactor(insights): metadata-only CTAs, platform DB, reports parent
Address review feedback on PR #12194:

- Pivot CTAs to pure descriptors (id/label/action/params). Drop the
  server-side execution layer: Action interface, registry, the
  databases.indexes.create CTA action, the params validator, the
  /v1/insights/:id/ctas/:id/executions endpoint, the InsightCTAExecution
  model, the INSIGHT_CTA_* errors, and the corresponding events. The
  console invokes the existing public API directly with the descriptor's
  action + params.

- Restore Databases\Indexes\Action.php to its pre-CTA shape and inline
  the index-create body back into Create.php (the createIndex helper
  was added solely for CTA reuse).

- Move insights collection from project DB to platform DB and add a
  parent reports collection alongside it. Insights carry projectId /
  projectInternalId for tenant scoping and an optional reportId for
  grouping. List endpoints filter by projectInternalId; Get/Update/
  Delete also enforce project ownership before touching the document.

- New Reports module with full CRUD (Create/Get/XList/Update/Delete),
  Report response model, Reports query validator, REPORT_NOT_FOUND /
  REPORT_ALREADY_EXISTS errors, reports.read / reports.write scopes,
  and reports.* event tree. Delete cascades to child insights.

- Update.php now mutates the loaded document via setAttribute (instead
  of passing a partial new Document), reuses CTAsValidator (instead of
  the looser ArrayList<JSON> + isset check), and rejects duplicate CTA
  ids.

- Create.php enforces unique CTA ids during normalization.

- CTAsValidator gained a configurable maxCount (default 16) so the
  Create path matches the Update path and the DB column size, and
  oversized payloads return a clean 400.

- Validator\Queries\Insights adds status and reportId to
  ALLOWED_ATTRIBUTES so dismissal / report workflows are filterable.

- Realtime channel parser guards $parts[1] for both insights and
  reports event names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:46:07 +12:00
ArnabChatterjee20k d043b4740e updated 2026-05-05 19:11:05 +05:30
ArnabChatterjee20k 6eb84c8235 Merge remote-tracking branch 'origin/presence-api' into realtime-messsage-dispatchers 2026-05-05 18:48:05 +05:30
harsh mahajan 61cee5892a fix: tighten commit skip directive matching 2026-05-05 18:23:14 +05:30
Harsh MahajanandGitHub a77558807c Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-05-05 17:58:54 +05:30
ArnabChatterjee20k f455bb7a58 refactor: add checks for queueForEvents and queueForRealtime in container setup; enhance presence resolution in tests 2026-05-05 17:11:49 +05:30
ArnabChatterjee20k bdf6c660d9 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-05-05 11:53:43 +05:30
ArnabChatterjee20k b0a2ef271a updated the queue for event propagation 2026-05-05 11:35:49 +05:30
Torsten Dittmann 5c7e08fdff Fix deployment upload analyze warning 2026-05-04 17:59:27 +04:00
Torsten Dittmann fd83090215 Fix parallel deployment chunk uploads 2026-05-04 17:34:53 +04:00
ArnabChatterjee20k 2ed8247fba fix graphql tests 2026-05-04 17:39:34 +05:30
ArnabChatterjee20k b2b6fb6b69 Enhance validation logic in Realtime message validators. Update error handling to return specific validation error messages and streamline the validation process in SubscribePayloadValidator and UnsubscribePayloadValidator. 2026-05-04 17:25:39 +05:30
ArnabChatterjee20k 4822164e32 Inject global helpers into PresenceHandler for improved testability and maintainability. 2026-05-04 17:14:09 +05:30
ArnabChatterjee20k 42542aec61 Enhance error handling and validation in Realtime message dispatchers. Update error messages for clarity and enforce required parameters in AuthenticationHandler. 2026-05-04 17:11:35 +05:30
Torsten Dittmann 6e19db130e Fix storage chunk upload events 2026-05-04 15:09:52 +04:00
ArnabChatterjee20k ec0f7cf683 Update error message in PresenceRealtimeClientTest for clarity on permissions validation 2026-05-04 16:39:47 +05:30
ArnabChatterjee20k ced5b88b57 Refactor message dispatching and handler parameters for improved clarity and consistency. Update AuthenticationHandler and PresenceHandler to enforce required parameters. 2026-05-04 16:37:35 +05:30
Torsten Dittmann d58929b621 Restore storage upload lock TTL 2026-05-04 15:00:16 +04:00
ArnabChatterjee20k 29092b277b Update AuthenticationHandler to make session parameter optional and add validation for empty session tokens. Update error message in PresenceRealtimeClientTest for clarity. 2026-05-04 16:22:54 +05:30
Torsten Dittmann 9927e4bd8e Fix storage upload lock contention handling 2026-05-04 14:34:42 +04:00
ArnabChatterjee20k 0e0e40f991 fixed the container inheritance bug 2026-05-04 15:59:25 +05:30
Torsten Dittmann 3db776e2e5 Update parallel upload test concurrency 2026-05-04 14:24:01 +04:00
Torsten Dittmann 61ca4e3969 Fix parallel storage chunk upload state 2026-05-04 14:15:01 +04:00
harsh mahajanandClaude Sonnet 4.6 40c2e1a1fa feat: skip deployment on commit message pattern match
Add providerCommitSkipPatterns array field to functions and sites.
Any commit message containing one of the patterns (case-insensitive
substring) skips the VCS-triggered deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 13:09:38 +05:30
ArnabChatterjee20k 429454ca0e added handlers 2026-05-04 11:18:22 +05:30
Jake BarnbyandClaude Opus 4.7 56ef3b4cfa refactor(insights): nest CTA params validator under resource path
Moves Validator/CTAParams/DatabasesCreateIndex to
Validator/CTA/Databases/Index/Create so the validator hierarchy mirrors
the action hierarchy (Insights/CTA/Action/Databases/Indexes/Create).
Also tightens the Update and CTA execute endpoint descriptions to call
out the dismissal-via-status flow and what the execution result carries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:11:30 +12:00
Jake BarnbyandClaude Opus 4.7 719b1885bf refactor(insights): share index-create body between CE action and CTA
Move the body of the public databases create-index endpoint into a new
final `createIndex(...)` helper on the abstract Indexes Action so both
the HTTP-facing CE action and the insights CTA share one source of truth
for index validation, persistence, and queue dispatch.

The CTA's `Appwrite\Insights\CTA\Action` becomes a small interface
(`getName()` + `execute()`); the dispatcher now calls `$action->execute(...)`
directly instead of poking at Utopia Action internals via `getCallback()`
and `getParams()`. The CTA's `Create` extends the CE Indexes `Create`
so it inherits `createIndex()` for free, while keeping a no-op constructor
to skip the HTTP route registration that runs in the parent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:49:44 +12:00
Jake BarnbyandClaude Opus 4.7 1d215b2840 revert(insights): drop insight/project param declarations on CTA actions
The CTA execution dispatcher invokes the action callback positionally
with values that have already been validated (insight fetched and
asserted non-empty in the endpoint, project pulled from the request
context). Re-validating them through param declarations adds noise
without catching anything. Drop the declarations and the validator
classes/tests created for them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:22:28 +12:00
Jake BarnbyandClaude Opus 4.7 0fbf31bd9a refactor(insights): drop Document suffix from Insight/Project validators
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:18:27 +12:00
Jake BarnbyandClaude Opus 4.7 4d560bdff2 feat(insights): validate insight and project documents in CTA actions
Replace the no-op `fn () => true` validators (gated by skipValidation)
on the `insight` and `project` params with dedicated InsightDocument and
ProjectDocument validators. They check that the injected value is a
non-empty Document with the expected attributes, so a misconfigured
dispatcher or unbound injection fails fast with a useful message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:12:41 +12:00
Jake BarnbyandClaude Opus 4.7 69c637c72d style(insights): use aliased import for DatabasesIndexesCreate registry entry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:01:31 +12:00
Jake BarnbyandClaude Opus 4.7 242c7f75ad refactor(insights): nest databases create-index CTA under resource path
Move the action class from Action/DatabasesCreateIndex.php to
Action/Databases/Indexes/Create.php so the directory mirrors the
underlying resource hierarchy. Action name follows: databases.createIndex
becomes databases.indexes.create, with the constant renamed to
INSIGHT_CTA_ACTION_DATABASES_INDEXES_CREATE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:57:28 +12:00
Jake BarnbyandClaude Opus 4.7 f779c7aa3b refactor(insights): collapse dismissal into a status field on update
Dismissal was a sub-resource (POST /v1/insights/:id/dismissals) but a
dismissal is just a state transition, not a thing the client creates.
Drop the dedicated endpoint and add a `status` enum (`active` |
`dismissed`) to the insights schema, settable via the existing PATCH
update route. The server still derives `dismissedAt` and `dismissedBy`
on transition for audit/sorting, but the client-facing API is just a
single status toggle.

- Schema: add `status` attribute (default `active`)
- Constants: add `INSIGHT_STATUSES`
- Update endpoint: accept `status` param, derive dismissedAt/By on
  active <-> dismissed transitions
- Response model: add `status` rule
- Drop Insights/Dismissal/Create.php, the createInsightDismissal SDK
  method, the `insights.[id].dismissals.create` event, and the
  `insight.dismissal.create` audit
- E2E: replace testCreateDismissal with testDismissViaUpdate covering
  both directions of the toggle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:36:31 +12:00
Jake BarnbyandClaude Opus 4.7 cd539d972a refactor(insights): capitalise CTA acronym in identifiers
Project-specific override of the default camelCase-acronyms convention:
namespaces, class names, file paths, and SDK method names use `CTA` in
all caps. Touches all insights surfaces — directories, response models,
validators, container resource keys, and SDK method names like
`createInsightCTAExecution`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:27:45 +12:00
Jake BarnbyandClaude Opus 4.7 d188bd7a2e refactor(insights): register CTA registry on the global $register
Persist the insight CTA registry across requests by attaching it to the
boot-time global $register, mirroring the pattern used for `geodb`,
`passwordsDictionary`, `hooks`, etc. The container resource now looks
the registry up from `$register` instead of rebuilding it on every
request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:22:10 +12:00
Jake Barnby 3c4aceea48 ci(insights): add Insights to e2e test matrix 2026-05-01 14:04:49 +12:00
Jake Barnby e0d5164af2 test(insights): split e2e into base trait and server/console overlays
Extract the existing test methods into InsightsBase trait and
provide thin Custom{Server,Console}Test classes that compose the
base with the appropriate scope/side traits. Updates the dismissal
and CTA execution tests to the new sub-resource paths.
2026-05-01 14:04:36 +12:00
Jake Barnby da5a137b98 refactor(insights): nest CTA execution and dismissal as sub-resources
Move POST /v1/insights/:id/dismiss to /v1/insights/:id/dismissals
and POST /v1/insights/:id/ctas/:ctaId/trigger to
/v1/insights/:id/ctas/:ctaId/executions, with the corresponding
class moves into Http/Insights/Dismissal/Create.php and
Http/CTA/Execution/Create.php. Rename the response model to
InsightCtaExecution and update events.php to surface dismissal
and execution as resource events with create verbs. The reshape
matches the rest of the API where verbs hang off plural sub-resources.
2026-05-01 14:04:31 +12:00
Jake Barnby d15be21923 refactor(insights): use Ctas validator on create endpoint
Replace the inline CTA shape check with a dedicated Utopia validator
so the constraint surfaces as a normal 400 with a useful message
rather than a generic argument-invalid exception.
2026-05-01 13:42:01 +12:00
Jake Barnby f5d6f6e27c refactor(insights): use Utopia Platform Action and Registry
Replace the bespoke CtaAction/Registry with Utopia\Platform\Action
and Utopia\Registry\Registry. Implement DatabasesCreateIndex with
the full createDocument('indexes') + queue path used by the existing
indexes endpoint, validated via a dedicated Utopia validator. Drop
the obsolete unit tests (custom-Action contract) in favor of
validator-focused tests.
2026-05-01 13:40:27 +12:00
Jake Barnby 8beae59454 fix(insights): schema corrections from review
- Add inline comments listing possible values for enum-bounded attributes
- Use VAR_ID for resourceInternalId
- Drop trailing 0 from composite index lengths and use $sequence over $createdAt
- Drop redundant ORDER_ASC values (default direction)
2026-05-01 13:30:23 +12:00
Jake BarnbyandClaude Opus 4.7 68dc974271 test(insights): unit and e2e tests
Unit tests cover the CTA registry register/resolve/has/all behaviour
and the DatabasesCreateIndex action's name, scope, validation surface,
and not-implemented execute path. The e2e suite runs the full CRUD
lifecycle, dismiss, and CTA trigger paths against a real cloud project,
including authentication boundaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:40:59 +12:00
Jake BarnbyandClaude Opus 4.7 236e594195 feat(insights): add dismiss and CTA trigger endpoints
Dismiss is a convenience that stamps `dismissedAt` and `dismissedBy` so
analyzers can see an insight has been acknowledged without losing the
record. CTA trigger looks up the action in the runtime registry,
validates the params blob, executes the action, and returns the result
as an `InsightCtaResult`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:40:53 +12:00
Jake BarnbyandClaude Opus 4.7 5a8be81484 feat(insights): add CRUD endpoints
Adds the create, get, list, update, and delete endpoints under the
`insights` SDK namespace. Mutating endpoints are admin/key-only because
insights are produced by analyzers; reads are open to sessions and JWTs
so console UIs can surface them. Updates use sparse documents so unset
fields keep their existing value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:40:47 +12:00
Jake BarnbyandClaude Opus 4.7 7f7be46547 feat(insights): add module skeleton and registration
Adds the platform module so the http service is discoverable, registers
it on the Appwrite platform alongside the other modules, exposes the
`insights` and `insights.[insightId]` realtime channels via the messaging
adapter, and ships the queries validator covering the indexed attributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:40:34 +12:00
Jake BarnbyandClaude Opus 4.7 e1ddcd051c feat(insights): add schema, scopes, events, errors, constants
Wires the platform glue for insights: the `insights` collection on the
project database, the `insights.read` / `insights.write` scopes, the
`insights.[insightId]` event tree (including the nested `ctas.[ctaId].trigger`
event), the typed exceptions, and the runtime CTA registry resource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:40:28 +12:00
Jake BarnbyandClaude Opus 4.7 1c8cc6fc92 feat(insights): add response models
Adds the Insight, InsightCta, and InsightCtaResult response models and
registers their model identifiers on the Response class so endpoints can
serialise insights consistently across the SDK surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:40:22 +12:00
Jake BarnbyandClaude Opus 4.7 1889ccdd12 feat(insights): add CTA framework
Introduces the call-to-action framework: a server-side registry plus an
Action interface that lets analyzers offer one-click remediations alongside
each insight. Ships a stub `databases.createIndex` action whose execute()
defers to a future cloud-side implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:40:16 +12:00
ArnabChatterjee20k 8cef822e8f Refactor presence management to use userInternalId instead of userId for improved consistency and clarity. Updated relevant methods in PresenceState and Upsert classes to reflect this change, ensuring proper handling of user identifiers across the application. 2026-04-30 18:50:46 +05:30
ArnabChatterjee20k 6dae78a206 linting 2026-04-30 18:48:54 +05:30
ArnabChatterjee20k 18ae81bbb0 Refactor presence management by introducing caching mechanisms for list responses. Added methods for loading, saving, and purging cache fields in the PresenceState class. Updated API endpoints to utilize caching, including purge functionality in Update and Delete actions. Enhanced tests to verify cache behavior during presence updates and deletions. 2026-04-30 18:33:20 +05:30
ArnabChatterjee20k e03218d8fa reverted last change 2026-04-30 18:24:38 +05:30
ArnabChatterjee20k 4f030619da temp gitignore 2026-04-30 18:03:25 +05:30
ArnabChatterjee20k e267dcf9ab updated 2026-04-30 18:01:51 +05:30
ArnabChatterjee20k 9c1cbecda4 bypassed sdk example? 2026-04-30 17:58:39 +05:30
ArnabChatterjee20k 8597d48e41 added additonal property key to rename on the sdk generator 2026-04-30 17:47:31 +05:30
ArnabChatterjee20k 971858078c updated 2026-04-30 16:50:04 +05:30
ArnabChatterjee20k 88bbb965f8 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-04-30 16:41:48 +05:30
ArnabChatterjee20k 5510bc4f6a Update userInternalId attribute type to VAR_ID and remove lengths from index definition for consistency. 2026-04-30 16:38:26 +05:30
ArnabChatterjee20k d802c57cb9 Refactor presence management by introducing a new 'presenceLogs' collection and updating related API methods. Added error handling for presence not found scenarios and standardized parameter names across the Update and XList actions. Removed obsolete migration version 25 and adjusted migration mapping for version 1.9.4. 2026-04-30 16:22:28 +05:30
ArnabChatterjee20k 32dc870eaf Refactor presence API functions and update expiry test to improve clarity and maintainability. Renamed 'triggerPresenceEvent' to 'getQueueForEventsForProject' and adjusted expiry handling in tests for better validation. 2026-04-30 15:56:31 +05:30
ArnabChatterjee20k 2c06e4185f Update presence expiry datetime validation to enforce a 30-day maximum limit. This change standardizes the 'expiresAt' parameter across the Update and Upsert actions, ensuring consistent handling of presence expiry across the API. 2026-04-30 13:30:25 +05:30
ArnabChatterjee20k 869c35416d Refactor presence API to standardize scope and attribute names. Updated 'presence' to 'presences' across roles, scopes, and HTTP methods for consistency. Renamed 'expiry' to 'expiresAt' in various locations to improve clarity and maintainability. 2026-04-30 13:30:15 +05:30
ArnabChatterjee20k e6c3acb21c Refactor presence API by removing the Action class and updating related classes to extend PlatformAction. This change enhances consistency across the API methods and improves code maintainability. 2026-04-30 13:04:28 +05:30
harsh mahajan e7405b4393 addressed comments 2026-04-30 13:01:00 +05:30
Harsh MahajanandGitHub 3eabca1d7a Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-04-30 12:10:16 +05:30
Harsh MahajanandGitHub ec984de510 Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-04-29 17:23:08 +05:30
ArnabChatterjee20k 9d6be1eef4 updated 2026-04-29 15:04:28 +05:30
ArnabChatterjee20k 06a05311a4 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-04-29 15:04:20 +05:30
ArnabChatterjee20k 58c248f4f2 Add migration for version 25 to ensure presenceLogs collection exists; update migration mapping for version 1.9.3. Remove redundant error assertion in PresenceTest. 2026-04-29 14:32:57 +05:30
ArnabChatterjee20k 59ee0901c9 Enhance presence API methods by adding detailed parameter specifications for update and upsert actions. Separate client-side and server-side SDK method definitions to clarify authentication requirements and improve usability. This update includes new parameters for presence management, ensuring better handling of user presence logs. 2026-04-29 14:20:38 +05:30
harsh mahajan 2d3c1086ae add endpoints 2026-04-29 13:50:40 +05:30
ArnabChatterjee20k e624040e57 Refactor presence API methods for clarity and consistency. Updated method names to include 'Presence' suffix for better identification. Enhanced presence state logic to support unique index-based upserts and improved test coverage for presence functionalities, including custom permissions and expiry handling. 2026-04-29 13:49:57 +05:30
ArnabChatterjee20k 496b91480b updated 2026-04-29 11:33:48 +05:30
ArnabChatterjee20k b08f3bdc52 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-04-29 11:29:59 +05:30
Harsh MahajanandGitHub 9c646c2893 Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-04-29 11:25:05 +05:30
Jake Barnby 77942af4ea Merge remote-tracking branch 'origin/1.9.x' into feat-dedicated-db 2026-04-29 13:53:52 +12:00
ArnabChatterjee20k 41b2962e64 updated usage name 2026-04-28 18:03:26 +05:30
ArnabChatterjee20k 25ae2e7314 Implement triggerStats function, add GraphQL source header, and enhance presence upsert logic with source detection. Also, introduce PresenceTest for GraphQL presence upsert validation. 2026-04-28 17:48:23 +05:30
ArnabChatterjee20k 8d378720b0 add presence API metrics and usage tracking 2026-04-28 17:19:24 +05:30
ArnabChatterjee20k 34f782d986 updated roles for the admin and members users 2026-04-28 17:16:24 +05:30
ArnabChatterjee20k 587a039493 fixed syntax 2026-04-28 13:57:31 +05:30
ArnabChatterjee20k 93ee8f45ea updated scopes 2026-04-28 13:50:11 +05:30
ArnabChatterjee20k 5157da870f add presence specific read and write scope instead of depending on the users read and write scope 2026-04-28 12:19:03 +05:30
ArnabChatterjee20k 6fabfe08ce Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-04-28 11:40:58 +05:30
Harsh MahajanandGitHub 1eb7bf7619 Merge branch '1.9.x' into feat-ser-401-custom-triggers 2026-04-27 15:14:58 +05:30
ArnabChatterjee20k a17f9ce989 removed comments 2026-04-24 17:30:19 +05:30
ArnabChatterjee20k 053d25c64e Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-04-24 17:29:39 +05:30
Jake Barnby 8ca59f22c9 Merge remote-tracking branch 'origin/1.9.x' into feat-dedicated-db 2026-04-22 11:41:42 +12:00
harsh mahajan 63f04f893d fix composer 2026-04-20 12:18:26 +05:30
Hemachandarandharsh mahajan aa5543ac0a filter out null values 2026-04-20 12:02:07 +05:30
Hemachandarandharsh mahajan 60e51548e8 more tests 2026-04-20 12:02:07 +05:30
Hemachandarandharsh mahajan e802102c8f fix exclusion sub-directory 2026-04-20 12:02:07 +05:30
Hemachandarandharsh mahajan 87f29c6889 include previous_filename 2026-04-20 12:02:07 +05:30
Hemachandarandharsh mahajan a70d241da0 fix path trigger 2026-04-20 12:02:07 +05:30
Hemachandarandharsh mahajan e17cdc2bd4 fix pattern logic 2026-04-20 12:02:07 +05:30
Hemachandarandharsh mahajan a4f174ea7e update VCS 2026-04-20 12:02:07 +05:30
Hemachandarandharsh mahajan 175fcb51a8 remove default 2026-04-20 12:00:27 +05:30
Hemachandarandharsh mahajan 772358ff51 more tests 2026-04-20 12:00:27 +05:30
Hemachandarandharsh mahajan abad809a91 get PR files in Appwrite 2026-04-20 12:00:27 +05:30
Hemachandarandharsh mahajan 076e64886e validator pattern 2026-04-20 11:59:57 +05:30
harsh mahajan 88107ee7b3 feat: implement custom triggers for VCS builds 2026-04-20 11:57:57 +05:30
Jake Barnby db720fbb22 (chore): update lock 2026-04-20 17:58:06 +12:00
Jake Barnby d92580470d Merge remote-tracking branch 'origin/1.9.x' into feat-dedicated-db 2026-04-20 17:57:01 +12:00
Jake BarnbyandClaude Opus 4.7 fc714de11d (diag): remove realtime-auth-diag / realtime-project-diag logging
Root causes are fixed: Realtime now purges both projects:$projectId
and users:$userId from the shared cache before the respective reads,
eliminating the cross-process negative-cache races that surfaced as
intermittent 'Session is not valid.' failures on
appwrite-labs/cloud#3214's Realtime (dedicated) E2E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 07:00:39 +12:00
Jake BarnbyandClaude Opus 4.7 92e8334169 (fix): purge cached project document before Realtime auth lookup
Cross-process negative-cache race in Realtime's onMessage handler: when
the WebSocket opens against a just-created project, an earlier router
probe or subscription lookup may have cached projects:\$projectId as
empty before the project actually existed. Although the HTTP worker
purges on createDocument, a narrow window lets Realtime re-populate the
stale empty entry in its own process cache, after which getProjectDB()
sees an empty Document and falls back to getConsoleDB(), which looks
for users in the console namespace and returns nothing — sessionVerify
then fails with 'Session is not valid.'

Surfaces on cloud#3214 as a recurring Realtime (dedicated) E2E failure.
Diagnosed via [realtime-project-diag] + [realtime-auth-diag]
instrumentation — the logs show consoleDb=appwrite consoleNs=console15x
(correct), project lookup empty, and subsequent user lookup landing in
the console namespace.

Purge is bounded: one cache DEL per WS message (not per read), runs
only when a projectId is present, and only forces the single
projects:\$projectId key to be re-read from adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 06:13:44 +12:00
Jake Barnby 8716497d1f (diag): log project lookup result before getProjectDB fallback 2026-04-18 05:42:49 +12:00
Jake Barnby d8486612de (diag): re-add realtime-auth-diag with session ids + shared-env 2026-04-18 04:10:44 +12:00
Jake BarnbyandClaude Opus 4.7 1225aa4861 (fix): translate NotFound from find() into 404 on listDocuments
When a DocumentsDB collection's metadata document exists but the
backing store (e.g. a freshly restored dedicated shard) has no table,
Utopia\Database\Database::find() throws NotFound. Previously this
bubbled to the HTTP layer as a generic 500. Catch it in the same
try/catch that already handles OrderException, QueryException, and
Timeout, and translate into COLLECTION_NOT_FOUND/TABLE_NOT_FOUND via
getParentNotFoundException() so the caller sees the correct 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:47:33 +12:00
Jake Barnby e8006dce6e (diag): remove realtime-auth-diag logging (root cause fixed in cloud) 2026-04-18 00:37:38 +12:00
Jake Barnby 894d771a7c (diag): include db/namespace/tenant/host in realtime-auth-diag 2026-04-18 00:17:13 +12:00
Jake BarnbyandClaude Opus 4.7 4e928ee08b (diag): log Realtime session-verify failure context
Temporary instrumentation to diagnose the 'Session is not valid.' failure
on appwrite-labs/cloud#3214's Realtime (dedicated) E2E. Emits:
  - project ID in scope
  - user ID from the session payload
  - whether the user document was found in the project DB
  - session count on the returned user document
  - session-secret prefix being verified
  - sessionVerify result

This will reveal whether the race is (a) user doc missing in project DB
(routing/provisioning issue) or (b) user doc found but sessions array
empty or mismatched (session write path issue). Revert once the root
cause is identified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:08:44 +12:00
Jake BarnbyandClaude Opus 4.7 4da9873b83 (fix): purge cached user document before Realtime session verification
Cross-process read-after-write race: the HTTP worker writes a new session
into the user document on /account login endpoints, then the client sends
an authentication frame over a different Swoole process (Realtime). Cache
propagation between processes is not guaranteed to be observed on the
very next read, so sessionVerify() occasionally fails with a stale user
document whose sessions array does not yet contain the just-created one.

Purge the cached user locally before the read so sessionVerify sees the
freshly-written session deterministically. Overhead is bounded: one DEL
on cache + one extra primary-key read, executed once per WebSocket
authentication frame (not per message).

Surfaces on PR appwrite-labs/cloud#3214 as an intermittent
'Session is not valid.' failure in RealtimeConsoleClientTest +
RealtimeCustomClientTest manual-authentication cases in dedicated mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:27:28 +12:00
Jake Barnby 082110bba8 Merge branch '1.9.x' into feat-dedicated-db 2026-04-17 22:25:51 +12:00
ArnabChatterjee20k 7167fe2fb4 Merge remote-tracking branch 'origin/presence-api' into presence-api 2026-04-17 12:18:13 +05:30
ArnabChatterjee20k d1c3ee0290 updated upsert logic 2026-04-17 12:17:57 +05:30
ArnabChatterjee20kandGitHub 6bd3821804 Merge branch '1.9.x' into presence-api 2026-04-17 12:16:56 +05:30
ArnabChatterjee20k 1d32faff4f updated realtime 2026-04-17 12:04:55 +05:30
ArnabChatterjee20k e67016584f updated the schema 2026-04-17 12:02:26 +05:30
ArnabChatterjee20k a4b6d9a635 updated tests and reduce the timeouts 2026-04-17 12:02:17 +05:30
ArnabChatterjee20k 1f27769cc9 added catching of the exceptions 2026-04-17 12:02:07 +05:30
ArnabChatterjee20k 0d2a4da347 updated the presence helper 2026-04-17 12:01:56 +05:30
ArnabChatterjee20k ee34e2b587 created a helper for the duplicated logic between realtime and http api 2026-04-17 12:01:34 +05:30
ArnabChatterjee20k 52dbf14d79 updated ci 2026-04-17 12:01:04 +05:30
ArnabChatterjee20k 70f42a0dff added presences to the tests 2026-04-16 18:16:09 +05:30
ArnabChatterjee20k 2ef62b309e Refactor presence upsert logic to handle duplicate entries. Added exception handling for MongoDB adapter to update existing documents instead of creating new ones when presenceId mismatches occur. 2026-04-16 18:14:13 +05:30
ArnabChatterjee20k a480d8ed73 updated realtime 2026-04-16 18:11:01 +05:30
ArnabChatterjee20k 4fea92c9cb Refactor realtime authorization handling and enhance presence event tests. Updated authorization roles synchronization in the realtime connection and added exception handling for user retrieval. Improved connection pool size logic for PubSub workers and added comprehensive tests for presence events to ensure correct message ordering and validation. 2026-04-16 17:47:34 +05:30
ArnabChatterjee20k 05add106c1 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-04-16 12:44:29 +05:30
ArnabChatterjee20k 0e3a017d50 added realtime trigger 2026-04-16 12:35:50 +05:30
ArnabChatterjee20k d28cce761d updated to have permissions order deterministic 2026-04-16 11:20:52 +05:30
ArnabChatterjee20k abb96f650b fix analyze 2026-04-16 11:18:39 +05:30
ArnabChatterjee20k 1785a78fa1 added event validation tests 2026-04-16 11:15:04 +05:30
ArnabChatterjee20k 3f06b5cd1a Enhance presence deletion by adding payload to event queue after document removal 2026-04-16 11:13:37 +05:30
Jake Barnby 95d5d1bdd9 Merge remote-tracking branch 'origin/1.9.x' into feat-dedicated-db 2026-04-16 01:15:10 +12:00
ArnabChatterjee20k cd6a6956d6 added channels and events generation 2026-04-15 18:16:51 +05:30
ArnabChatterjee20k 7e18e6f8c5 updated realtime and tests 2026-04-15 16:30:17 +05:30
ArnabChatterjee20k 968b1c0861 updated configs 2026-04-15 16:11:05 +05:30
ArnabChatterjee20k 2ce39768c3 added delete of presence on disconnect 2026-04-15 13:38:34 +05:30
ArnabChatterjee20k 69bc935517 Add permission handling for presence updates and enhance error validation in realtime messages 2026-04-15 13:21:26 +05:30
ArnabChatterjee20k 26ff78d98a updated expiry tests 2026-04-13 22:39:56 +05:30
ArnabChatterjee20k 0935c60f3c added users.read, users.write currently for the scoping 2026-04-13 22:37:17 +05:30
ArnabChatterjee20k 54fa813bc6 Implement presence expiry management and enhance user ID handling in presence actions. Add automated tests for expired presence deletion during maintenance. 2026-04-13 22:31:46 +05:30
ArnabChatterjee20k f9d0cb7daf Refactor presence tests to handle unauthorized access for client side operations and simplify presence setup logic. 2026-04-13 21:08:23 +05:30
ArnabChatterjee20k c5d5946fd2 updated scopes 2026-04-13 21:08:17 +05:30
ArnabChatterjee20k af41c64135 Merge remote-tracking branch 'origin/1.9.x' into presence-api 2026-04-13 20:47:32 +05:30
ArnabChatterjee20k 63afcad23d tests 2026-04-13 20:47:13 +05:30
ArnabChatterjee20k eb76c31fdd updated aciton source 2026-04-13 20:46:30 +05:30
ArnabChatterjee20k 51fd96f4bf added crud operation 2026-04-13 20:28:40 +05:30
ArnabChatterjee20k 14351c7338 updated model 2026-04-13 20:28:31 +05:30
ArnabChatterjee20k 01c6cc86ce Presence api response models 2026-04-13 20:28:23 +05:30
ArnabChatterjee20k c6f16cde4a updated schema 2026-04-13 11:44:01 +05:30
Jake Barnby 8412790436 Merge remote-tracking branch 'origin/1.9.x' into feat-dedicated-db 2026-04-09 14:02:49 +12:00
Jake Barnby fdca14e659 (feat): precomputed guage write in stats resources 2026-04-07 23:45:59 +12:00
Jake Barnby e3b1eead55 Merge remote-tracking branch 'origin/1.9.x' into feat-dedicated-db
# Conflicts:
#	app/config/collections/projects.php
2026-04-07 23:24:08 +12:00
Jake Barnby 11fc170d72 (docs): Add documentsdb and vectorsdb reference docs 2026-03-25 22:53:03 +13:00
Jake Barnby 467b2a9281 (feat): Increase databases.database attribute size to 2048 2026-03-25 18:39:49 +13:00
ArnabChatterjee20k 3b2c8a4d36 added schema for presence api 2026-03-24 14:29:36 +05:30
Prem Palanisamy 043e7efb55 Update phpseclib to 3.0.50 to fix CVE-2026-32935 2026-03-23 13:46:58 +00:00
Prem Palanisamy 91cda84772 Update composer.lock after rebase onto 1.9.x 2026-03-23 13:46:58 +00:00
Prem Palanisamy 90641041ef Pass projectInternalId to destination migration 2026-03-23 13:46:58 +00:00
Prem Palanisamy e2f2ce3da8 Change console-key endpoint from GET to POST 2026-03-23 13:46:58 +00:00
Prem Palanisamy 374e1f8782 Add unit tests for scopedProjectId and source fields in Key::decode() 2026-03-23 13:46:58 +00:00
Prem Palanisamy b0a0c1552e Update composer.lock for utopia-php/migration dev branch 2026-03-23 13:46:58 +00:00
Prem Palanisamy 4020335aac Add E2E test for platform migration 2026-03-23 13:46:58 +00:00
Prem Palanisamy 75c26259a1 Add expire field to migration console key response 2026-03-23 13:46:58 +00:00
Prem Palanisamy 4e8feff46e Add platform migration support 2026-03-23 13:46:58 +00:00
Prem Palanisamy 4438e08f64 Add platform DB access via console-key endpoint 2026-03-23 13:46:57 +00:00
310 changed files with 12878 additions and 2679 deletions
+3 -1
View File
@@ -427,6 +427,7 @@ jobs:
FunctionsSchedule,
GraphQL,
Health,
Advisor,
Locale,
Projects,
Realtime,
@@ -441,7 +442,8 @@ jobs:
VCS,
Messaging,
Migrations,
Project
Project,
Presences
]
include:
- service: Databases
+8
View File
@@ -115,6 +115,14 @@ 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.
## Tracing with Utopia Span
In handlers, only call `Span::add($key, $value)`. **Never** call `Span::init`, `Span::error`, or `Span::finish` -- lifecycle is owned by the entry-point harness (`app/http.php`, `app/worker.php`, `app/realtime.php`, `Bus::dispatch`). For selective export, filter in the sampler in `app/init/span.php`.
Keys are `snake_case` with dots only for child relationships: `project.id` (id of project), `storage.bucket.id`. No dot otherwise: `inbound_bytes`, not `inbound.bytes`. No camelCase, no bare top-level keys (`function.id`, not `functionId`).
Cross-cutting identifiers (`project.id`, `function.id`, `user.id`) live at the top level, not under a subsystem (no `realtime.project.id`). The trace sampler and downstream filters look them up by the canonical key.
## 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.
+2 -54
View File
@@ -2,17 +2,10 @@
require_once __DIR__ . '/init.php';
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\Platform\Appwrite;
use Appwrite\Runtimes\Runtimes;
use Appwrite\Usage\Context as UsageContext;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Swoole\Runtime;
use Swoole\Timer;
use Utopia\Cache\Adapter\Pool as CachePool;
@@ -26,17 +19,12 @@ use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Platform\Service;
use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Registry\Registry;
use Utopia\System\System;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
use function Swoole\Coroutine\run;
@@ -47,6 +35,7 @@ Config::setParam('runtimes', (new Runtimes('v5'))->getAll(supported: false));
require_once __DIR__ . '/controllers/general.php';
global $register;
global $container;
$platform = new Appwrite();
$args = $_SERVER['argv'] ?? [];
@@ -58,7 +47,6 @@ if (! isset($args[0])) {
}
$taskName = $args[0];
$container = new Container();
$cli = new CLI(new Generic(), $_SERVER['argv'] ?? [], $container);
$platform->setCli($cli);
@@ -131,10 +119,6 @@ $container->set('dbForPlatform', function ($pools, $cache, $authorization) {
return $dbForPlatform;
}, ['pools', 'cache', 'authorization']);
$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
$container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false,
@@ -251,42 +235,10 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
return $database;
};
}, ['pools', 'cache', 'authorization']);
$container->set('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
$container->set('publisherDatabases', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherFunctions', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
$container->set('usage', function () {
return new UsageContext();
}, []);
$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['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']);
$container->set('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
$container->set('logError', function (Registry $register) {
return function (Throwable $error, string $namespace, string $action) use ($register) {
Console::error('[Error] Timestamp: ' . date('c', time()));
@@ -339,14 +291,10 @@ $container->set('logError', function (Registry $register) {
};
}, ['register']);
$container->set('executor', fn () => new Executor(), []);
$container->set('bus', function (Registry $register) use ($container) {
return $register->get('bus')->setResolver(fn (string $name) => $container->get($name));
}, ['register']);
$container->set('telemetry', fn () => new NoTelemetry(), []);
$exitCode = 0;
$cli
+434
View File
@@ -1956,6 +1956,440 @@ $platformCollections = [
'attributes' => [],
'indexes' => []
],
'reports' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('reports'),
'name' => 'Reports',
'attributes' => [
[
'$id' => ID::custom('projectInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => 0,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('projectId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('appInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => 0,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('appId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('type'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('title'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('summary'),
'type' => Database::VAR_TEXT,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
// Resource type the report is about. Plural noun, e.g. databases, sites, urls.
'$id' => ID::custom('targetType'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
// Free-form target identifier (URL for lighthouse, resource ID for db).
// Indexed by `_key_project_target` with an explicit prefix length.
'$id' => ID::custom('target'),
'type' => Database::VAR_TEXT,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
// Category strings, e.g. 'performance', 'accessibility'. Native array
// column — we never query on individual entries (MySQL JSON-array
// indexes are weak), this is read+rewrite only.
'$id' => ID::custom('categories'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => false,
'default' => null,
'array' => true,
'filters' => [],
],
[
// Virtual attribute — insights live in the `insights` collection
// back-referenced by `reportInternalId`. The subQuery filter joins
// them at read time.
'$id' => ID::custom('insights'),
'type' => Database::VAR_TEXT,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['subQueryReportInsights'],
],
[
'$id' => ID::custom('analyzedAt'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
],
'indexes' => [
[
'$id' => ID::custom('_key_project_app_type'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'appInternalId', 'type'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_target'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'appInternalId', 'targetType', 'target'],
'lengths' => [null, null, null, 700],
'orders' => [],
],
],
],
'insights' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('insights'),
'name' => 'Insights',
'attributes' => [
[
'$id' => ID::custom('projectInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => 0,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('projectId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('reportInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => 0,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('reportId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('type'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('severity'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 16,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('status'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 16,
'signed' => true,
'required' => true,
'default' => 'active',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('resourceType'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('resourceId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('resourceInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => 0,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('parentResourceType'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('parentResourceId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('parentResourceInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => 0,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('title'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('summary'),
'type' => Database::VAR_TEXT,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('ctas'),
'type' => Database::VAR_TEXT,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['json'],
],
[
'$id' => ID::custom('analyzedAt'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
[
'$id' => ID::custom('dismissedAt'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
[
'$id' => ID::custom('dismissedBy'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
],
'indexes' => [
[
'$id' => ID::custom('_key_project_report'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'reportInternalId'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_resource'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'resourceType', 'resourceId'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_parent_resource'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_type'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'type'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_severity'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'severity'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_status'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'status'],
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_dismissedAt'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'dismissedAt'],
'lengths' => [],
'orders' => [Database::ORDER_ASC, Database::ORDER_DESC],
],
],
],
];
// Organization API keys subquery
+186
View File
@@ -841,6 +841,28 @@ return [
'array' => true,
'filters' => [],
],
[
'$id' => ID::custom('providerBranches'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
[
'$id' => ID::custom('providerPaths'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
],
'indexes' => [
[
@@ -1320,6 +1342,28 @@ return [
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('providerBranches'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
[
'$id' => ID::custom('providerPaths'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
],
'indexes' => [
[
@@ -2754,4 +2798,146 @@ return [
],
],
],
// Naming it presenceLogs as later it might be only be used as a presence events table only and not for the actual presence
'presenceLogs' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('presenceLogs'),
'name' => 'Presence Logs',
'attributes' => [
[
'$id' => ID::custom('userInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => true,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('userId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('expiresAt'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
[
'$id' => ID::custom('status'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('source'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => true,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('hostname'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('metadata'),
'type' => Database::VAR_TEXT,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => false,
'default' => new \stdClass(),
'array' => false,
'filters' => ['json'],
],
[
'$id' => ID::custom('permissionsHash'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 32,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
],
'indexes' => [
[
'$id' => ID::custom('_unique_userId'),
'type' => Database::INDEX_UNIQUE,
'attributes' => ['userId'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC]
],
[
'$id' => ID::custom('_key_userInternal'),
'type' => Database::INDEX_KEY,
'attributes' => ['userInternalId'],
'orders' => [Database::ORDER_ASC]
],
[
'$id' => ID::custom('_key_expiresAt'),
'type' => Database::INDEX_KEY,
'attributes' => ['expiresAt'],
'lengths' => [],
'orders' => [Database::ORDER_ASC]
],
[
'$id' => ID::custom('_key_status'),
'type' => Database::INDEX_KEY,
'attributes' => ['status'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC]
],
[
'$id' => ID::custom('_key_source'),
'type' => Database::INDEX_KEY,
'attributes' => ['source'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC]
],
[
'$id' => ID::custom('_key_source_status'),
'type' => Database::INDEX_KEY,
'attributes' => ['source', 'status']
],
[
'$id' => ID::custom('_key_permissionsHash'),
'type' => Database::INDEX_KEY,
'attributes' => ['permissionsHash']
]
]
]
];
+1
View File
@@ -22,6 +22,7 @@ return [
'X-Appwrite-Locale',
'X-Appwrite-Mode',
'X-Appwrite-JWT',
'X-Appwrite-Organization',
'X-Appwrite-Response-Format',
'X-Appwrite-Timeout',
'X-Appwrite-ID',
+36
View File
@@ -725,6 +725,18 @@ return [
'code' => 404,
],
/** Presence */
Exception::PRESENCE_NOT_FOUND => [
'name' => Exception::PRESENCE_NOT_FOUND,
'description' => 'Presence with the requested ID could not be found.',
'code' => 404,
],
Exception::PRESENCE_ALREADY_EXISTS => [
'name' => Exception::PRESENCE_ALREADY_EXISTS,
'description' => 'Presence with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
/** Databases */
Exception::DATABASE_NOT_FOUND => [
'name' => Exception::DATABASE_NOT_FOUND,
@@ -1453,4 +1465,28 @@ return [
'description' => 'The maximum number of mock phones for this project has been reached.',
'code' => 400,
],
/** Advisor */
Exception::INSIGHT_NOT_FOUND => [
'name' => Exception::INSIGHT_NOT_FOUND,
'description' => 'Insight with the requested ID could not be found.',
'code' => 404,
],
Exception::INSIGHT_ALREADY_EXISTS => [
'name' => Exception::INSIGHT_ALREADY_EXISTS,
'description' => 'Insight with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
/** Reports */
Exception::REPORT_NOT_FOUND => [
'name' => Exception::REPORT_NOT_FOUND,
'description' => 'Report with the requested ID could not be found.',
'code' => 404,
],
Exception::REPORT_ALREADY_EXISTS => [
'name' => Exception::REPORT_ALREADY_EXISTS,
'description' => 'Report with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
];
+29 -1
View File
@@ -426,5 +426,33 @@ return [
'update' => [
'$description' => 'This event triggers when a proxy rule is updated.',
]
]
],
'reports' => [
'$model' => Response::MODEL_REPORT,
'$resource' => true,
'$description' => 'This event triggers on any report event.',
'create' => [
'$description' => 'This event triggers when a report is created.',
],
'update' => [
'$description' => 'This event triggers when a report is updated.',
],
'delete' => [
'$description' => 'This event triggers when a report is deleted.',
],
'insights' => [
'$model' => Response::MODEL_INSIGHT,
'$resource' => true,
'$description' => 'This event triggers on any insight event.',
'create' => [
'$description' => 'This event triggers when an insight is created.',
],
'update' => [
'$description' => 'This event triggers when an insight is updated.',
],
'delete' => [
'$description' => 'This event triggers when an insight is deleted.',
],
],
],
];
+70 -14
View File
@@ -14,7 +14,11 @@ return [
'name' => 'Analog',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/analog/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/analog/env.sh',
'adapters' => [
@@ -40,7 +44,11 @@ return [
'name' => 'Angular',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/angular/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/angular/env.sh',
'adapters' => [
@@ -66,7 +74,11 @@ return [
'name' => 'Next.js',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/next-js/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/next-js/env.sh',
'adapters' => [
@@ -91,7 +103,11 @@ return [
'name' => 'React',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'adapters' => [
'static' => [
'key' => 'static',
@@ -108,7 +124,11 @@ return [
'name' => 'Nuxt',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/nuxt/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/nuxt/env.sh',
'adapters' => [
@@ -133,7 +153,11 @@ return [
'name' => 'Vue.js',
'screenshotSleep' => 5000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'adapters' => [
'static' => [
'key' => 'static',
@@ -150,7 +174,11 @@ return [
'name' => 'SvelteKit',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/sveltekit/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/sveltekit/env.sh',
'adapters' => [
@@ -175,7 +203,11 @@ return [
'name' => 'Astro',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/astro/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/astro/env.sh',
'adapters' => [
@@ -200,7 +232,11 @@ return [
'name' => 'TanStack Start',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/tanstack-start/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/tanstack-start/env.sh',
'adapters' => [
@@ -225,7 +261,11 @@ return [
'name' => 'Remix',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'bundleCommand' => 'bash /usr/local/server/helpers/remix/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/remix/env.sh',
'adapters' => [
@@ -250,7 +290,11 @@ return [
'name' => 'Lynx',
'screenshotSleep' => 5000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'adapters' => [
'static' => [
'key' => 'static',
@@ -284,7 +328,11 @@ return [
'name' => 'React Native',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'adapters' => [
'static' => [
'key' => 'static',
@@ -301,7 +349,11 @@ return [
'name' => 'Vite',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'adapters' => [
'static' => [
'key' => 'static',
@@ -317,7 +369,11 @@ return [
'name' => 'Other',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
'runtimes' => $templateRuntimes['NODE'],
'runtimes' => array_merge(
$templateRuntimes['NODE'],
$templateRuntimes['BUN'],
$templateRuntimes['DENO']
),
'adapters' => [
'static' => [
'key' => 'static',
+8
View File
@@ -12,6 +12,8 @@ $member = [
'account',
'teams.read',
'teams.write',
'presences.read',
'presences.write',
'documents.read',
'documents.write',
'rows.read',
@@ -47,6 +49,8 @@ $admins = [
'buckets.write',
'users.read',
'users.write',
'presences.read',
'presences.write',
'databases.read',
'databases.write',
'collections.read',
@@ -103,6 +107,10 @@ $admins = [
'tokens.write',
'schedules.read',
'schedules.write',
'insights.read',
'insights.write',
'reports.read',
'reports.write',
];
return [
+8 -2
View File
@@ -4,17 +4,23 @@
return [
"projects.read" => [
"description" => 'Access to read organization\'s projects',
"description" => 'Access to read organization projects',
"category" => "Projects",
],
"projects.write" => [
"description" =>
"Access to create, update, and delete projects in organization",
"Access to create, update, and delete organization projects",
"category" => "Projects",
],
"devKeys.read" => [
"description" => 'Access to read project\'s development keys',
"category" => "Other",
"deprecated" => true,
],
"devKeys.write" => [
"description" =>
"Access to create, update, and delete project\'s development keys",
"category" => "Other",
"deprecated" => true,
],
];
+26
View File
@@ -361,4 +361,30 @@ return [
'description' => 'Access to create, update, and delete resources under VCS service.',
'category' => 'Other',
],
// Advisor
'insights.read' => [
'description' => 'Access to read insights under Advisor service.',
'category' => 'Advisor',
],
'insights.write' => [
'description' => 'Reserved for Advisor insight ingestion outside CE.',
'category' => 'Advisor',
],
'reports.read' => [
'description' => 'Access to read reports under Advisor service.',
'category' => 'Advisor',
],
'reports.write' => [
'description' => 'Access to delete reports under Advisor service.',
'category' => 'Advisor',
],
'presences.read' => [
'description' => 'Access to read your project\'s presences',
'category' => 'Presences',
],
'presences.write' => [
'description' => 'Access to create, update, and delete your project\'s presences',
'category' => 'Presences',
],
];
+49 -49
View File
@@ -8,55 +8,6 @@ return [
'enabled' => true,
'beta' => false,
'sdks' => [
[
'key' => 'web',
'name' => 'Web',
'version' => '22.4.0',
'url' => 'https://github.com/appwrite/sdk-for-web',
'package' => 'https://www.npmjs.com/package/appwrite',
'enabled' => true,
'beta' => false,
'dev' => false,
'hidden' => false,
'family' => APP_SDK_PLATFORM_CLIENT,
'prism' => 'javascript',
'source' => \realpath(__DIR__ . '/../sdks/client-web'),
'gitUrl' => 'git@github.com:appwrite/sdk-for-web.git',
'gitRepoName' => 'sdk-for-web',
'gitUserName' => 'appwrite',
'gitBranch' => 'dev',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/web/CHANGELOG.md'),
'demos' => [
[
'icon' => 'react.svg',
'name' => 'Todo App with React JS',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-react',
'url' => 'https://appwrite-todo-with-react.vercel.app/',
],
[
'icon' => 'vue.svg',
'name' => 'Todo App with Vue JS',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-vue',
'url' => 'https://appwrite-todo-with-vue.vercel.app/',
],
[
'icon' => 'angular.svg',
'name' => 'Todo App with Angular',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-angular',
'url' => 'https://appwrite-todo-with-angular.vercel.app/',
],
[
'icon' => 'svelte.svg',
'name' => 'Todo App with Svelte',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-svelte',
'url' => 'https://appwrite-todo-with-svelte.vercel.app/',
],
]
],
[
'key' => 'flutter',
'name' => 'Flutter',
@@ -350,6 +301,55 @@ return [
'enabled' => true,
'beta' => false,
'sdks' => [
[
'key' => 'web',
'name' => 'Web',
'version' => '22.4.0',
'url' => 'https://github.com/appwrite/sdk-for-web',
'package' => 'https://www.npmjs.com/package/appwrite',
'enabled' => true,
'beta' => false,
'dev' => false,
'hidden' => false,
'family' => APP_SDK_PLATFORM_SERVER,
'prism' => 'javascript',
'source' => \realpath(__DIR__ . '/../sdks/client-web'),
'gitUrl' => 'git@github.com:appwrite/sdk-for-web.git',
'gitRepoName' => 'sdk-for-web',
'gitUserName' => 'appwrite',
'gitBranch' => 'dev',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/web/CHANGELOG.md'),
'demos' => [
[
'icon' => 'react.svg',
'name' => 'Todo App with React JS',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-react',
'url' => 'https://appwrite-todo-with-react.vercel.app/',
],
[
'icon' => 'vue.svg',
'name' => 'Todo App with Vue JS',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-vue',
'url' => 'https://appwrite-todo-with-vue.vercel.app/',
],
[
'icon' => 'angular.svg',
'name' => 'Todo App with Angular',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-angular',
'url' => 'https://appwrite-todo-with-angular.vercel.app/',
],
[
'icon' => 'svelte.svg',
'name' => 'Todo App with Svelte',
'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
'source' => 'https://github.com/appwrite/todo-with-svelte',
'url' => 'https://appwrite-todo-with-svelte.vercel.app/',
],
]
],
[
'key' => 'nodejs',
'name' => 'Node.js',
+15 -1
View File
@@ -308,5 +308,19 @@ return [
'optional' => true,
'icon' => '/images/services/messaging.png',
'platforms' => ['client', 'server', 'console'],
]
],
'advisor' => [
'key' => 'advisor',
'name' => 'Advisor',
'subtitle' => 'The Advisor service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.',
'description' => '/docs/services/advisor.md',
'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/server/advisor',
'tests' => true,
'optional' => true,
'icon' => '/images/services/insights.png',
'platforms' => ['server', 'console'],
],
];
+33 -27
View File
@@ -11,10 +11,11 @@ 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;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Appwrite\Event\Message\Mail as MailMessage;
use Appwrite\Event\Message\Messaging as MessagingMessage;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
@@ -472,9 +473,9 @@ Http::delete('/v1/account')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('authorization')
->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) {
->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, DeletePublisher $publisherForDeletes, Authorization $authorization) {
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
@@ -498,9 +499,11 @@ Http::delete('/v1/account')
$dbForProject->deleteDocument('users', $user->getId());
$queueForDeletes
->setType(DELETE_TYPE_DOCUMENT)
->setDocument($user);
$publisherForDeletes->enqueue(new DeleteMessage(
project: $project,
type: DELETE_TYPE_DOCUMENT,
document: $user,
));
$queueForEvents
->setParam('userId', $user->getId())
@@ -582,12 +585,12 @@ Http::delete('/v1/account/sessions')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('store')
->inject('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) {
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessions = $user->getAttribute('sessions', []);
@@ -617,10 +620,11 @@ Http::delete('/v1/account/sessions')
$queueForEvents
->setPayload($response->output($session, Response::MODEL_SESSION));
$queueForDeletes
->setType(DELETE_TYPE_SESSION_TARGETS)
->setDocument($session)
->trigger();
$publisherForDeletes->enqueue(new DeleteMessage(
project: $queueForEvents->getProject(),
type: DELETE_TYPE_SESSION_TARGETS,
document: $session,
));
}
}
@@ -714,12 +718,12 @@ Http::delete('/v1/account/sessions/:sessionId')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('store')
->inject('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) {
->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessionId = ($sessionId === 'current')
@@ -761,10 +765,11 @@ Http::delete('/v1/account/sessions/:sessionId')
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION));
$queueForDeletes
->setType(DELETE_TYPE_SESSION_TARGETS)
->setDocument($session)
->trigger();
$publisherForDeletes->enqueue(new DeleteMessage(
project: $queueForEvents->getProject(),
type: DELETE_TYPE_SESSION_TARGETS,
document: $session,
));
$response->noContent();
return;
@@ -1234,7 +1239,6 @@ Http::get('/v1/account/sessions/oauth2/:provider')
)
],
contentType: ContentType::HTML,
hide: [APP_SDK_PLATFORM_SERVER],
))
->label('abuse-limit', 50)
->label('abuse-key', 'ip:{ip}')
@@ -4510,7 +4514,7 @@ Http::post('/v1/account/targets/push')
group: 'pushTargets',
name: 'createPushTarget',
description: '/docs/references/account/create-push-target.md',
auth: [AuthType::ADMIN, AuthType::SESSION],
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
@@ -4594,7 +4598,7 @@ Http::put('/v1/account/targets/:targetId/push')
group: 'pushTargets',
name: 'updatePushTarget',
description: '/docs/references/account/update-push-target.md',
auth: [AuthType::ADMIN, AuthType::SESSION],
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
@@ -4664,7 +4668,7 @@ Http::delete('/v1/account/targets/:targetId/push')
group: 'pushTargets',
name: 'deletePushTarget',
description: '/docs/references/account/delete-push-target.md',
auth: [AuthType::ADMIN, AuthType::SESSION],
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
@@ -4675,13 +4679,13 @@ Http::delete('/v1/account/targets/:targetId/push')
))
->param('targetId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Target ID.', false, ['dbForProject'])
->inject('queueForEvents')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('user')
->inject('request')
->inject('response')
->inject('dbForProject')
->inject('authorization')
->action(function (string $targetId, Event $queueForEvents, Delete $queueForDeletes, Document $user, Request $request, Response $response, Database $dbForProject, Authorization $authorization) {
->action(function (string $targetId, Event $queueForEvents, DeletePublisher $publisherForDeletes, Document $user, Request $request, Response $response, Database $dbForProject, Authorization $authorization) {
$target = $authorization->skip(fn () => $dbForProject->getDocument('targets', $targetId));
if ($target->isEmpty()) {
@@ -4696,9 +4700,11 @@ Http::delete('/v1/account/targets/:targetId/push')
$dbForProject->purgeCachedDocument('users', $user->getId());
$queueForDeletes
->setType(DELETE_TYPE_TARGET)
->setDocument($target);
$publisherForDeletes->enqueue(new DeleteMessage(
project: $queueForEvents->getProject(),
type: DELETE_TYPE_TARGET,
document: $target,
));
$queueForEvents
->setParam('userId', $user->getId())
+9 -6
View File
@@ -3,9 +3,10 @@
use Ahc\Jwt\JWT;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Appwrite\Event\Message\Messaging as MessagingMessage;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Messaging\Status as MessageStatus;
@@ -2728,9 +2729,9 @@ Http::delete('/v1/messaging/topics/:topicId')
->param('topicId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Topic ID.', false, ['dbForProject'])
->inject('queueForEvents')
->inject('dbForProject')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('response')
->action(function (string $topicId, Event $queueForEvents, Database $dbForProject, Delete $queueForDeletes, Response $response) {
->action(function (string $topicId, Event $queueForEvents, Database $dbForProject, DeletePublisher $publisherForDeletes, Response $response) {
$topic = $dbForProject->getDocument('topics', $topicId);
if ($topic->isEmpty()) {
@@ -2739,9 +2740,11 @@ Http::delete('/v1/messaging/topics/:topicId')
$dbForProject->deleteDocument('topics', $topicId);
$queueForDeletes
->setType(DELETE_TYPE_TOPIC)
->setDocument($topic);
$publisherForDeletes->enqueue(new DeleteMessage(
project: $queueForEvents->getProject(),
type: DELETE_TYPE_TOPIC,
document: $topic,
));
$queueForEvents
->setParam('topicId', $topic->getId());
+16 -11
View File
@@ -11,8 +11,9 @@ use Appwrite\Auth\Validator\Phone;
use Appwrite\Deletes\Identities as DeleteIdentities;
use Appwrite\Deletes\Targets as DeleteTargets;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\SDK\AuthType;
@@ -2592,8 +2593,8 @@ Http::delete('/v1/users/:userId')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->inject('queueForDeletes')
->action(function (string $userId, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) {
->inject('publisherForDeletes')
->action(function (string $userId, Response $response, Database $dbForProject, Event $queueForEvents, DeletePublisher $publisherForDeletes) {
$user = $dbForProject->getDocument('users', $userId);
@@ -2608,9 +2609,11 @@ Http::delete('/v1/users/:userId')
DeleteIdentities::delete($dbForProject, Query::equal('userInternalId', [$user->getSequence()]));
DeleteTargets::delete($dbForProject, Query::equal('userInternalId', [$user->getSequence()]));
$queueForDeletes
->setType(DELETE_TYPE_DOCUMENT)
->setDocument($clone);
$publisherForDeletes->enqueue(new DeleteMessage(
project: $queueForEvents->getProject(),
type: DELETE_TYPE_DOCUMENT,
document: $clone,
));
$queueForEvents
->setParam('userId', $user->getId())
@@ -2643,10 +2646,10 @@ Http::delete('/v1/users/:userId/targets/:targetId')
->param('userId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'User ID.', false, ['dbForProject'])
->param('targetId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Target ID.', false, ['dbForProject'])
->inject('queueForEvents')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('response')
->inject('dbForProject')
->action(function (string $userId, string $targetId, Event $queueForEvents, Delete $queueForDeletes, Response $response, Database $dbForProject) {
->action(function (string $userId, string $targetId, Event $queueForEvents, DeletePublisher $publisherForDeletes, Response $response, Database $dbForProject) {
$user = $dbForProject->getDocument('users', $userId);
if ($user->isEmpty()) {
@@ -2666,9 +2669,11 @@ Http::delete('/v1/users/:userId/targets/:targetId')
$dbForProject->deleteDocument('targets', $target->getId());
$dbForProject->purgeCachedDocument('users', $user->getId());
$queueForDeletes
->setType(DELETE_TYPE_TARGET)
->setDocument($target);
$publisherForDeletes->enqueue(new DeleteMessage(
project: $queueForEvents->getProject(),
type: DELETE_TYPE_TARGET,
document: $target,
));
$queueForEvents
->setParam('userId', $user->getId())
+33 -55
View File
@@ -7,9 +7,10 @@ use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Bus\Events\RequestCompleted;
use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Appwrite\Event\Publisher\Certificate;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Network\Cors;
use Appwrite\Platform\Appwrite;
@@ -74,7 +75,7 @@ use Utopia\Validator\Text;
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)
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, DeletePublisher $publisherForDeletes, int $executionsRetentionCount)
{
$host = $request->getHostname();
if (!empty($previewHostname)) {
@@ -183,31 +184,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
/** @var Database $dbForProject */
$dbForProject = $getProjectDB($project);
if (!empty($rule->getAttribute('deploymentId', ''))) {
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId')));
} else {
// 1.6.x DB schema compatibility
// TODO: Make sure deploymentId is never empty, and remove this code
// Check if site or function; should never be site, but better safe than sorry
// Attempts to use attribute from both schemas (1.6 and 1.7)
$resourceType = $rule->getAttribute('deploymentResourceType', $rule->getAttribute('resourceType', ''));
// ID of site or function
$resourceId = $rule->getAttribute('deploymentResourceId', '');
// Document of site or function
$resource = $resourceType === 'function' ?
$authorization->skip(fn () => $dbForProject->getDocument('functions', $resourceId)) :
$authorization->skip(fn () => $dbForProject->getDocument('sites', $resourceId));
// ID of active deployments
// Attempts to use attribute from both schemas (1.6 and 1.7)
$activeDeploymentId = $resource->getAttribute('deploymentId', $resource->getAttribute('deployment', ''));
// Get deployment document, as intended originally
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId));
}
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId')));
if ($deployment->isEmpty()) {
$resourceType = $rule->getAttribute('deploymentResourceType', '');
@@ -790,12 +767,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
? RESOURCE_TYPE_FUNCTIONS
: RESOURCE_TYPE_SITES;
$queueForDeletes
->setProject($project)
->setResourceType($resourceType)
->setResource($resource->getSequence())
->setType(DELETE_TYPE_EXECUTIONS_LIMIT)
->trigger();
$publisherForDeletes->enqueue(new DeleteMessage(
project: $project,
type: DELETE_TYPE_EXECUTIONS_LIMIT,
resource: (string) $resource->getSequence(),
resourceType: $resourceType,
));
}
return true;
@@ -856,9 +833,10 @@ Http::init()
->inject('apiKey')
->inject('cors')
->inject('authorization')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->inject('params')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount, array $params) {
/*
* Appwrite Router
*/
@@ -866,15 +844,15 @@ Http::init()
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->match($request)?->route->label('router', true);
}
}
/*
* Request format
*/
$route = $utopia->getRoute();
$route = $utopia->match($request)?->route;
$request->setRoute($route);
if ($route === null) {
@@ -899,7 +877,7 @@ Http::init()
}
if (version_compare($requestFormat, '1.8.0', '<')) {
$dbForProject = $getProjectDB($project);
$request->addFilter(new RequestV20($dbForProject, $route->getPathValues($request)));
$request->addFilter(new RequestV20($dbForProject, $params));
}
if (version_compare($requestFormat, '1.9.0', '<')) {
$request->addFilter(new RequestV21());
@@ -1167,17 +1145,17 @@ Http::options()
->inject('apiKey')
->inject('cors')
->inject('authorization')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->match($request)?->route->label('router', true);
}
}
@@ -1212,7 +1190,7 @@ Http::error()
->inject('authorization')
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$route = $utopia->getRoute();
$route = $utopia->match($request)?->route;
$class = \get_class($error);
$code = $error->getCode();
$message = $error->getMessage();
@@ -1282,7 +1260,7 @@ Http::error()
if (!$publish && $project->getId() !== 'console') {
$errorUser = new DBUser();
try {
$resolvedUser = $utopia->getResource('user');
$resolvedUser = $utopia->context()->get('user');
if ($resolvedUser instanceof DBUser) {
$errorUser = $resolvedUser;
}
@@ -1301,7 +1279,7 @@ Http::error()
if ($logger && $publish) {
try {
/** @var Utopia\Database\Document $user */
$user = $utopia->getResource('user');
$user = $utopia->context()->get('user');
} catch (\Throwable) {
// All good, user is optional information for logger
}
@@ -1502,7 +1480,7 @@ Http::error()
// the cors resource (which depends on rule -> DB) would cascade.
// Uses override:true to avoid duplicate headers if init() already set them.
try {
$cors = $utopia->getResource('cors');
$cors = $utopia->context()->get('cors');
foreach ($cors->headers($request->getOrigin()) as $name => $value) {
$response
->removeHeader($name)
@@ -1569,16 +1547,16 @@ Http::get('/robots.txt')
->inject('previewHostname')
->inject('apiKey')
->inject('authorization')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->match($request)?->route->label('router', true);
}
}
});
@@ -1603,16 +1581,16 @@ Http::get('/humans.txt')
->inject('previewHostname')
->inject('apiKey')
->inject('authorization')
->inject('queueForDeletes')
->inject('publisherForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->match($request)?->route->label('router', true);
}
}
});
+3 -4
View File
@@ -13,6 +13,7 @@ use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\UID;
use Utopia\Http\Http;
use Utopia\Http\Route;
use Utopia\Locale\Locale;
use Utopia\System\System;
use Utopia\Validator\Text;
@@ -283,13 +284,11 @@ Http::get('/v1/mock/github/callback')
Http::shutdown()
->groups(['mock'])
->inject('utopia')
->inject('response')
->inject('request')
->action(function (Http $utopia, Response $response, Request $request) {
->inject('route')
->action(function (Response $response, Route $route) {
$result = [];
$route = $utopia->getRoute();
$path = APP_STORAGE_CACHE . '/tests.json';
$tests = (\file_exists($path)) ? \json_decode(\file_get_contents($path), true) : [];
+100 -116
View File
@@ -4,13 +4,12 @@ use Appwrite\Auth\Key;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Bus\Events\RequestCompleted;
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\Message\Audit as AuditMessage;
use Appwrite\Event\Message\Func as FunctionMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
use Appwrite\Event\Publisher\Audit;
use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
@@ -38,6 +37,7 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
use Utopia\Database\Validator\Roles;
use Utopia\Http\Http;
use Utopia\Http\Route;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
@@ -86,7 +86,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
Http::init()
->groups(['api'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('dbForPlatform')
->inject('dbForProject')
@@ -99,11 +99,7 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('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);
}
->action(function (Route $route, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
/**
* Handle user authentication and session validation.
@@ -478,109 +474,110 @@ Http::init()
Http::init()
->groups(['api'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('response')
->inject('project')
->inject('user')
->inject('timelimit')
->inject('devKey')
->inject('authorization')
->action(function (Route $route, Request $request, Response $response, Document $project, User $user, callable $timelimit, Document $devKey, Authorization $authorization) {
$response->setUser($user);
$request->setUser($user);
$roles = $authorization->getRoles();
$shouldCheckAbuse = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'
&& ! $user->isApp($roles)
&& ! $user->isPrivileged($roles)
&& $devKey->isEmpty();
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
$abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
$closestLimit = null;
foreach ($abuseKeyLabel as $abuseKey) {
$isRateLimited = false;
try {
$start = $request->getContentRangeStart();
$end = $request->getContentRangeEnd();
$timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600));
$timeLimit
->setParam('{projectId}', $project->getId())
->setParam('{userId}', $user->getId())
->setParam('{userAgent}', $request->getUserAgent(''))
->setParam('{ip}', $request->getIP())
->setParam('{url}', $request->getHostname() . $route->getPath())
->setParam('{method}', $request->getMethod())
->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
foreach ($request->getParams() as $key => $value) {
if (! empty($value)) {
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
}
}
$abuse = new Abuse($timeLimit);
$remaining = $timeLimit->remaining();
$limit = $timeLimit->limit();
$time = $timeLimit->time() + $route->getLabel('abuse-time', 3600);
if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) {
$closestLimit = $remaining;
$response
->addHeader('X-RateLimit-Limit', $limit)
->addHeader('X-RateLimit-Remaining', $remaining)
->addHeader('X-RateLimit-Reset', $time);
}
if ($shouldCheckAbuse) {
$isRateLimited = $abuse->check();
}
} catch (\Throwable $th) {
\error_log((string) $th);
continue;
}
if ($isRateLimited) {
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED);
}
}
});
Http::init()
->groups(['api'])
->inject('route')
->inject('request')
->inject('response')
->inject('project')
->inject('user')
->inject('queueForEvents')
->inject('auditContext')
->inject('queueForDeletes')
->inject('queueForDatabase')
->inject('usage')
->inject('queueForFunctions')
->inject('publisherForFunctions')
->inject('dbForProject')
->inject('timelimit')
->inject('resourceToken')
->inject('mode')
->inject('apiKey')
->inject('plan')
->inject('devKey')
->inject('telemetry')
->inject('platform')
->inject('authorization')
->inject('cacheControlForStorage')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
->action(function (Route $route, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
$response->setUser($user);
$request->setUser($user);
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
}
$path = $route->getMatchedPath();
$path = $route->getPath();
$databaseType = match (true) {
str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
default => '',
};
/*
* Abuse Check
*/
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
$timeLimitArray = [];
$abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
foreach ($abuseKeyLabel as $abuseKey) {
$start = $request->getContentRangeStart();
$end = $request->getContentRangeEnd();
$timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600));
$timeLimit
->setParam('{projectId}', $project->getId())
->setParam('{userId}', $user->getId())
->setParam('{userAgent}', $request->getUserAgent(''))
->setParam('{ip}', $request->getIP())
->setParam('{url}', $request->getHostname() . $route->getPath())
->setParam('{method}', $request->getMethod())
->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
$timeLimitArray[] = $timeLimit;
}
$closestLimit = null;
$roles = $authorization->getRoles();
$isPrivilegedUser = $user->isPrivileged($roles);
$isAppUser = $user->isApp($roles);
foreach ($timeLimitArray as $timeLimit) {
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
if (! empty($value)) {
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
}
}
$abuse = new Abuse($timeLimit);
$remaining = $timeLimit->remaining();
$limit = $timeLimit->limit();
$time = $timeLimit->time() + $route->getLabel('abuse-time', 3600);
if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) {
$closestLimit = $remaining;
$response
->addHeader('X-RateLimit-Limit', $limit)
->addHeader('X-RateLimit-Remaining', $remaining)
->addHeader('X-RateLimit-Reset', $time);
}
$enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled';
if (
$enabled // Abuse is enabled
&& ! $isAppUser // User is not API key
&& ! $isPrivilegedUser // User is not an admin
&& $devKey->isEmpty() // request doesn't not contain development key
&& $abuse->check() // Route is rate-limited
) {
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED);
}
}
/**
* TODO: (@loks0n)
* Avoid mutating the message across file boundaries - it's difficult to reason about at scale.
@@ -610,20 +607,13 @@ Http::init()
$auditContext->user = $userClone;
}
/* Auto-set projects */
$queueForDeletes->setProject($project);
$queueForDatabase->setProject($project);
$queueForFunctions->setProject($project);
/* Auto-set platforms */
$queueForFunctions->setPlatform($platform);
$useCache = $route->getLabel('cache', false);
$storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load');
if ($useCache) {
$route = $utopia->match($request);
$roles = $authorization->getRoles();
$isAppUser = $user->isApp($roles);
$isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview';
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles());
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($roles);
$key = $request->cacheIdentifier();
Span::add('storage.cache.key', $key);
@@ -644,7 +634,7 @@ Http::init()
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($roles);
if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -757,12 +747,11 @@ Http::init()
*/
Http::shutdown()
->groups(['session'])
->inject('utopia')
->inject('request')
->inject('response')
->inject('project')
->inject('dbForProject')
->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) {
->action(function (Request $request, Response $response, Document $project, Database $dbForProject) {
$sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0;
if ($sessionLimit === 0) {
@@ -796,7 +785,7 @@ Http::shutdown()
Http::shutdown()
->groups(['api'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('response')
->inject('project')
@@ -806,9 +795,7 @@ Http::shutdown()
->inject('publisherForAudits')
->inject('usage')
->inject('publisherForUsage')
->inject('queueForDeletes')
->inject('queueForDatabase')
->inject('queueForFunctions')
->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('queueForRealtime')
->inject('dbForProject')
@@ -818,7 +805,7 @@ Http::shutdown()
->inject('bus')
->inject('apiKey')
->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, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
->action(function (Route $route, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -847,9 +834,15 @@ Http::shutdown()
if (! empty($functionsEvents)) {
foreach ($generatedEvents as $event) {
if (isset($functionsEvents[$event])) {
$queueForFunctions
->from($queueForEvents)
->trigger();
$publisherForFunctions->enqueue(FunctionMessage::fromEvent(
event: $queueForEvents->getEvent(),
params: $queueForEvents->getParams(),
project: $queueForEvents->getProject(),
user: $queueForEvents->getUser(),
userId: $queueForEvents->getUserId(),
payload: $queueForEvents->getPayload(),
platform: $queueForEvents->getPlatform(),
));
break;
}
}
@@ -868,7 +861,6 @@ Http::shutdown()
}
}
$route = $utopia->getRoute();
$requestParams = $route->getParamsValues();
/**
@@ -959,14 +951,6 @@ Http::shutdown()
$publisherForAudits->enqueue(AuditMessage::fromContext($auditContext));
}
if (! empty($queueForDeletes->getType())) {
$queueForDeletes->trigger();
}
if (! empty($queueForDatabase->getType())) {
$queueForDatabase->trigger();
}
// Cache label
$useCache = $route->getLabel('cache', false);
if ($useCache) {
+3 -4
View File
@@ -9,6 +9,7 @@ use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Http\Http;
use Utopia\Http\Route;
use Utopia\System\System;
Http::init()
@@ -32,13 +33,13 @@ Http::init()
Http::init()
->groups(['auth'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('project')
->inject('geodb')
->inject('user')
->inject('authorization')
->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) {
->action(function (Route $route, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) {
$denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');
if (!empty($denylist && $project->getId() === 'console')) {
$countries = explode(',', $denylist);
@@ -49,8 +50,6 @@ Http::init()
}
}
$route = $utopia->match($request);
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
+33 -41
View File
@@ -3,7 +3,7 @@
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
$registerRequestResources = require __DIR__ . '/init/resources/request.php';
$setRequestContext = require __DIR__ . '/init/resources/request.php';
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -26,6 +26,7 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\DI\Container;
use Utopia\Http\Adapter\Swoole\Server;
use Utopia\Http\Files;
use Utopia\Http\Http;
@@ -57,7 +58,7 @@ $container->set('pools', function ($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(
$swoole = new Server(
host: "0.0.0.0",
port: System::getEnv('PORT', 80),
settings: [
@@ -69,10 +70,10 @@ $swooleAdapter = new Server(
Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
],
container: $container,
resources: $container,
);
$http = $swooleAdapter->getServer();
$http = $swoole->getServer();
/**
* Assigns HTTP requests to worker threads by analyzing its payload/content.
@@ -190,13 +191,11 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) {
Console::success('Reload completed...');
});
$container->set('bus', function ($register) use ($swooleAdapter) {
return $register->get('bus')->setResolver(fn (string $name) => $swooleAdapter->getContainer()->get($name));
}, ['register']);
$container->set('bus', fn ($register) => $register->get('bus')->setResolver(fn (string $name) => $swoole->context()->get($name)), ['register']);
include __DIR__ . '/controllers/general.php';
function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
function createDatabase(Container $resources, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
{
$max = 15;
$sleep = 2;
@@ -205,7 +204,7 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
while (true) {
try {
$attempts++;
$resource = $app->getResource($resourceKey);
$resource = $resources->get($resourceKey);
/* @var $database Database */
$database = is_callable($resource) ? $resource() : $resource;
break; // exit loop on success
@@ -288,23 +287,21 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
Span::current()?->finish();
}
$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $swooleAdapter) {
$app = new Http($swooleAdapter, 'UTC');
$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $container) {
/** @var \Utopia\Pools\Group $pools */
$pools = $app->getResource('pools');
$pools = $container->get('pools');
go(function () use ($app, $pools) {
go(function () use ($container, $pools) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
// create logs database first, `getLogsDB` is a callable.
createDatabase($app, 'getLogsDB', 'logs', $collections['logs'], $pools);
createDatabase($container, 'getLogsDB', 'logs', $collections['logs'], $pools);
// create appwrite database, `dbForPlatform` is a direct access call.
createDatabase($app, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $app) {
$authorization = $app->getResource('authorization');
createDatabase($container, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $container) {
$authorization = $container->get('authorization');
if ($dbForPlatform->getCollection(AuditAdapterSQL::COLLECTION)->isEmpty()) {
$adapter = new AdapterDatabase($dbForPlatform);
@@ -416,7 +413,7 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke
$documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''));
$vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
$cache = $app->getResource('cache');
$cache = $container->get('cache');
// All shared tables pools that need project metadata collections
$allSharedTables = \array_values(\array_unique(\array_filter([
@@ -502,7 +499,7 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke
});
});
$swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swooleAdapter, $registerRequestResources) {
$swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoole, $setRequestContext) {
Span::init('http.request');
$request = new Request($utopiaRequest->getSwooleRequest());
@@ -522,21 +519,18 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
return;
}
$requestContainer = $swooleAdapter->getContainer();
$requestContainer->set('container', fn () => $requestContainer);
$requestContainer->set('request', fn () => $request);
$requestContainer->set('response', fn () => $response);
$app = new Http($swoole, 'UTC');
$app->context()->set('request', fn () => $request);
$app->context()->set('response', fn () => $response);
$app->context()->set('utopia', fn () => $app);
$app = new Http($swooleAdapter, 'UTC');
$requestContainer->set('utopia', fn () => $app);
$registerRequestResources($requestContainer);
$setRequestContext($app->context());
$app->setCompression(System::getEnv('_APP_COMPRESSION_ENABLED', 'enabled') === 'enabled');
$app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB
try {
$authorization = $app->getResource('authorization');
$authorization = $app->context()->get('authorization');
$request->setAuthorization($authorization);
$response->setAuthorization($authorization);
@@ -545,25 +539,25 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
$app->run($request, $response);
$route = $app->getRoute();
$route = $app->match($request)?->route;
Span::add('http.path', $route?->getPath() ?? 'unknown');
} catch (\Throwable $th) {
Span::error($th);
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$logger = $app->getResource("logger");
$logger = $app->context()->get("logger");
if ($logger) {
try {
/** @var Utopia\Database\Document $user */
$user = $app->getResource('user');
$user = $app->context()->get('user');
} catch (\Throwable $_th) {
// All good, user is optional information for logger
}
$route = $app->getRoute();
$route = $app->match($request)?->route;
$log = $app->getResource("log");
$log = $app->context()->get("log");
if (isset($user) && !$user->isEmpty()) {
$log->setUser(new User($user->getId()));
@@ -642,18 +636,16 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
});
// Fetch domains every `DOMAIN_SYNC_TIMER` seconds and update in the memory
$http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
$http->on(Constant::EVENT_TASK, function () use ($container) {
$lastSyncUpdate = null;
$app = new Http($swooleAdapter, 'UTC');
/** @var Utopia\Database\Database $dbForPlatform */
$dbForPlatform = $app->getResource('dbForPlatform');
$dbForPlatform = $container->get('dbForPlatform');
/** @var \Swoole\Table $riskyDomains */
$riskyDomains = $app->getResource('riskyDomains');
$riskyDomains = $container->get('riskyDomains');
Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $app) {
Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $container) {
try {
$time = DateTime::now();
$limit = 1000;
@@ -670,7 +662,7 @@ $http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
}
$results = [];
try {
$authorization = $app->getResource('authorization');
$authorization = $container->get('authorization');
$results = $authorization->skip(fn () => $dbForPlatform->find('rules', $queries));
} catch (Throwable $th) {
Console::error('rules ' . $th->getMessage());
@@ -720,4 +712,4 @@ $http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
});
});
$swooleAdapter->start();
$swoole->start();
+63 -2
View File
@@ -1,5 +1,11 @@
<?php
use Appwrite\Platform\Modules\Advisor\Enums\InsightCTAMethod;
use Appwrite\Platform\Modules\Advisor\Enums\InsightCTAService;
use Appwrite\Platform\Modules\Advisor\Enums\InsightSeverity;
use Appwrite\Platform\Modules\Advisor\Enums\InsightStatus;
use Appwrite\Platform\Modules\Advisor\Enums\InsightType;
use Appwrite\Platform\Modules\Advisor\Enums\ReportType;
use Appwrite\Platform\Modules\Compute\Specification;
use Utopia\System\System;
@@ -44,7 +50,7 @@ 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 = 4325;
const APP_CACHE_BUSTER = 4326;
const APP_VERSION_STABLE = '1.9.5';
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
@@ -194,7 +200,7 @@ const BUILD_TYPE_RETRY = 'retry';
const DELETE_TYPE_DATABASES = 'databases';
const DELETE_TYPE_DOCUMENT = 'document';
const DELETE_TYPE_COLLECTIONS = 'collections';
const DELETE_TYPE_TRANSACTION = 'transaction';
const DELETE_TYPE_TRANSACTIONS = 'transactions';
const DELETE_TYPE_EXPIRED_TRANSACTIONS = 'expired_transactions';
const DELETE_TYPE_PROJECTS = 'projects';
const DELETE_TYPE_SITES = 'sites';
@@ -222,6 +228,7 @@ const DELETE_TYPE_EXPIRED_TARGETS = 'invalid_targets';
const DELETE_TYPE_SESSION_TARGETS = 'session_targets';
const DELETE_TYPE_CSV_EXPORTS = 'csv_exports';
const DELETE_TYPE_MAINTENANCE = 'maintenance';
const DELETE_TYPE_REPORT = 'report';
// Rule statuses
const RULE_STATUS_CREATED = 'created'; // This is also the status when domain DNS verification fails.
@@ -387,6 +394,7 @@ const METRIC_NETWORK_OUTBOUND = 'network.outbound';
const METRIC_MAU = 'users.mau';
const METRIC_DAU = 'users.dau';
const METRIC_WAU = 'users.wau';
const METRIC_USERS_PRESENCE = 'users.presence';
const METRIC_WEBHOOKS = 'webhooks';
const METRIC_PLATFORMS = 'platforms';
const METRIC_PROVIDERS = 'providers';
@@ -424,6 +432,55 @@ const RESOURCE_TYPE_MESSAGES = 'messages';
const RESOURCE_TYPE_EXECUTIONS = 'executions';
const RESOURCE_TYPE_VCS = 'vcs';
const RESOURCE_TYPE_EMBEDDINGS_TEXT = 'embeddingsText';
const RESOURCE_TYPE_INSIGHTS = 'insights';
const RESOURCE_TYPE_REPORTS = 'reports';
// Insight types — engine-specific so the CTA action can reference the right public API.
const ADVISOR_INSIGHT_TYPES = [
InsightType::DATABASE_INDEX->value, // legacy databases.createIndex
InsightType::TABLES_DB_INDEX->value, // tablesDB.createIndex
InsightType::DOCUMENTS_DB_INDEX->value, // documentsDB.createIndex
InsightType::VECTORS_DB_INDEX->value, // vectorsDB.createIndex
InsightType::DATABASE_PERFORMANCE->value,
InsightType::SITE_PERFORMANCE->value,
InsightType::SITE_ACCESSIBILITY->value,
InsightType::SITE_SEO->value,
InsightType::FUNCTION_PERFORMANCE->value,
];
// Public API services (SDK namespaces) that an insight CTA's `service` can reference.
// Analyzers must pick the one matching the engine the resource lives in.
const ADVISOR_CTA_SERVICES = [
InsightCTAService::DATABASES->value, // legacy
InsightCTAService::TABLES_DB->value,
InsightCTAService::DOCUMENTS_DB->value,
InsightCTAService::VECTORS_DB->value,
];
// Public API method names that an insight CTA's `method` can reference for index suggestions.
const ADVISOR_CTA_METHODS = [
InsightCTAMethod::CREATE_INDEX->value,
];
// Insight severities
const ADVISOR_SEVERITIES = [
InsightSeverity::INFO->value,
InsightSeverity::WARNING->value,
InsightSeverity::CRITICAL->value,
];
// Insight statuses
const ADVISOR_STATUSES = [
InsightStatus::ACTIVE->value,
InsightStatus::DISMISSED->value,
];
// Report types
const ADVISOR_REPORT_TYPES = [
ReportType::LIGHTHOUSE->value,
ReportType::AUDIT->value,
ReportType::DATABASE_ANALYZER->value,
];
// Resource types for Tokens
const TOKENS_RESOURCE_TYPE_FILES = 'files';
@@ -458,3 +515,7 @@ const CSV_ALLOWED_DATABASE_TYPES = [
DATABASE_TYPE_TABLESDB,
DATABASE_TYPE_VECTORSDB
];
const VCS_DEPLOYMENT_SKIP_PATTERNS = [
'[skip ci]',
];
+14
View File
@@ -475,3 +475,17 @@ Database::addFilter(
]));
}
);
Database::addFilter(
'subQueryReportInsights',
function (mixed $value) {
return;
},
function (mixed $value, Document $document, Database $database) {
return $database->getAuthorization()->skip(fn () => $database->find('insights', [
Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]),
Query::equal('reportInternalId', [$document->getSequence()]),
Query::limit(APP_LIMIT_SUBQUERY),
]));
}
);
+14 -1
View File
@@ -92,6 +92,8 @@ use Appwrite\Utopia\Response\Model\HealthTime;
use Appwrite\Utopia\Response\Model\HealthVersion;
use Appwrite\Utopia\Response\Model\Identity;
use Appwrite\Utopia\Response\Model\Index;
use Appwrite\Utopia\Response\Model\Insight;
use Appwrite\Utopia\Response\Model\InsightCTA;
use Appwrite\Utopia\Response\Model\Installation;
use Appwrite\Utopia\Response\Model\JWT;
use Appwrite\Utopia\Response\Model\Key;
@@ -172,6 +174,7 @@ 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\Presence;
use Appwrite\Utopia\Response\Model\Project;
use Appwrite\Utopia\Response\Model\ProjectAuthMethod;
use Appwrite\Utopia\Response\Model\ProjectProtocol;
@@ -182,6 +185,7 @@ 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\Report;
use Appwrite\Utopia\Response\Model\ResourceToken;
use Appwrite\Utopia\Response\Model\Row;
use Appwrite\Utopia\Response\Model\Rule;
@@ -211,6 +215,7 @@ use Appwrite\Utopia\Response\Model\UsageDocumentsDB;
use Appwrite\Utopia\Response\Model\UsageDocumentsDBs;
use Appwrite\Utopia\Response\Model\UsageFunction;
use Appwrite\Utopia\Response\Model\UsageFunctions;
use Appwrite\Utopia\Response\Model\UsagePresence;
use Appwrite\Utopia\Response\Model\UsageProject;
use Appwrite\Utopia\Response\Model\UsageSite;
use Appwrite\Utopia\Response\Model\UsageSites;
@@ -234,6 +239,7 @@ Response::setModel(new ErrorDev());
// Lists
Response::setModel(new BaseList('Rows List', Response::MODEL_ROW_LIST, 'rows', Response::MODEL_ROW));
Response::setModel(new BaseList('Documents List', Response::MODEL_DOCUMENT_LIST, 'documents', Response::MODEL_DOCUMENT));
Response::setModel(new BaseList('Presences List', Response::MODEL_PRESENCE_LIST, 'presences', Response::MODEL_PRESENCE));
Response::setModel(new BaseList('Tables List', Response::MODEL_TABLE_LIST, 'tables', Response::MODEL_TABLE));
Response::setModel(new BaseList('Collections List', Response::MODEL_COLLECTION_LIST, 'collections', Response::MODEL_COLLECTION));
Response::setModel(new BaseList('Databases List', Response::MODEL_DATABASE_LIST, 'databases', Response::MODEL_DATABASE));
@@ -260,7 +266,7 @@ Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIS
Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME));
Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_LIST, 'deployments', Response::MODEL_DEPLOYMENT));
Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION));
Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false));
Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, true));
Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, true));
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));
@@ -291,6 +297,8 @@ Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICA
Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT));
Response::setModel(new BaseList('VectorsDB Collections List', Response::MODEL_VECTORSDB_COLLECTION_LIST, 'collections', Response::MODEL_VECTORSDB_COLLECTION));
Response::setModel(new BaseList('Embedding list', Response::MODEL_EMBEDDING_LIST, 'embeddings', Response::MODEL_EMBEDDING));
Response::setModel(new BaseList('Insights List', Response::MODEL_INSIGHT_LIST, 'insights', Response::MODEL_INSIGHT));
Response::setModel(new BaseList('Reports List', Response::MODEL_REPORT_LIST, 'reports', Response::MODEL_REPORT));
// Entities
Response::setModel(new Database());
@@ -356,6 +364,7 @@ Response::setModel(new Index());
Response::setModel(new ColumnIndex());
Response::setModel(new Row());
Response::setModel(new ModelDocument());
Response::setModel(new Presence());
Response::setModel(new Log());
Response::setModel(new User());
Response::setModel(new AlgoMd5());
@@ -484,6 +493,7 @@ Response::setModel(new UsageDatabase());
Response::setModel(new UsageTable());
Response::setModel(new UsageCollection());
Response::setModel(new UsageUsers());
Response::setModel(new UsagePresence());
Response::setModel(new UsageStorage());
Response::setModel(new UsageBuckets());
Response::setModel(new UsageFunctions());
@@ -515,6 +525,9 @@ Response::setModel(new Target());
Response::setModel(new Migration());
Response::setModel(new MigrationReport());
Response::setModel(new MigrationFirebaseProject());
Response::setModel(new Insight());
Response::setModel(new InsightCTA());
Response::setModel(new Report());
// Tests (keep last)
Response::setModel(new Mock());
+15 -1
View File
@@ -240,6 +240,12 @@ $register->set('pools', function () {
'multiple' => true,
'schemes' => ['redis'],
],
'lock' => [
'type' => 'lock',
'dsns' => $fallbackForRedis,
'multiple' => false,
'schemes' => ['redis'],
],
];
$maxConnections = (int) System::getEnv('_APP_CONNECTIONS_MAX', 151);
@@ -332,7 +338,13 @@ $register->set('pools', function () {
$poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool();
$pool = new Pool($poolAdapter, $name, $poolSize, function () use ($type, $resource, $dsn) {
// PubSub workers hold one long-lived subscribed connection and also need
// spare capacity for publishes from the same process.
$connectionPoolSize = $type === 'pubsub'
? max(2, $poolSize)
: $poolSize;
$pool = new Pool($poolAdapter, $name, $connectionPoolSize, function () use ($type, $resource, $dsn) {
// Get Adapter
switch ($type) {
case 'database':
@@ -369,6 +381,8 @@ $register->set('pools', function () {
}
return $adapter;
case 'lock':
return $resource();
default:
throw new Exception(Exception::GENERAL_SERVER_ERROR, "Server error: Missing adapter implementation.");
}
+101 -73
View File
@@ -4,7 +4,10 @@ use Appwrite\Event\Event;
use Appwrite\Event\Publisher\Audit as AuditPublisher;
use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Publisher\Certificate as CertificatePublisher;
use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Event\Publisher\Execution as ExecutionPublisher;
use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
@@ -26,6 +29,7 @@ use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Lock\Distributed;
use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Publisher;
@@ -49,6 +53,93 @@ global $register;
global $container;
$container = new Container();
$container->set('console', fn () => new Document(Config::getParam('console')), []);
$container->set('executor', fn () => new Executor(), []);
$container->set('telemetry', fn () => new NoTelemetry(), []);
$container->set('publisher', fn (Group $pools) => new BrokerPool(publisher: $pools->get('publisher')), ['pools']);
$container->set('publisherDatabases', fn (Publisher $publisher) => $publisher, ['publisher']);
$container->set('publisherFunctions', fn (Publisher $publisher) => $publisher, ['publisher']);
$container->set('publisherMigrations', fn (Publisher $publisher) => $publisher, ['publisher']);
$container->set('publisherMails', fn (Publisher $publisher) => $publisher, ['publisher']);
$container->set('publisherDeletes', fn (Publisher $publisher) => $publisher, ['publisher']);
$container->set('publisherMessaging', fn (Publisher $publisher) => $publisher, ['publisher']);
$container->set('publisherWebhooks', fn (Publisher $publisher) => $publisher, ['publisher']);
$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher(
$publisher,
new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME))
), ['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('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher(
$publisher,
new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
$publisher,
new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
$publisher,
new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
), ['publisher']);
$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher(
$publisher,
new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_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('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
$publisher,
new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher(
$publisherDatabases,
new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME))
), ['publisherDatabases']);
$container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher(
$publisher,
new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher(
$publisher,
new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher(
$publisher,
new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
), ['publisher']);
$container->set('logger', function ($register) {
return $register->get('logger');
}, ['register']);
@@ -63,71 +154,6 @@ $container->set('localeCodes', function () {
return array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', []));
});
// Queues - shared infrastructure (stateless pool wrappers)
$container->set('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
$container->set('publisherDatabases', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherFunctions', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherMigrations', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherMails', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherDeletes', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherMessaging', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherWebhooks', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher(
$publisher,
new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME))
), ['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('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher(
$publisher,
new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
$publisher,
new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher(
$publisher,
new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_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('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
$publisher,
new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher(
$publisher,
new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
), ['publisher']);
$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher(
$publisher,
new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
), ['publisher']);
/**
* Platform configuration
@@ -136,10 +162,6 @@ $container->set('platform', function () {
return Config::getParam('platform', []);
}, []);
$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
$container->set('authorization', function () {
return new Authorization();
}, []);
@@ -198,8 +220,6 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
};
}, ['pools', 'cache', 'authorization']);
$container->set('telemetry', fn () => new NoTelemetry());
$container->set('cache', function (Group $pools, Telemetry $telemetry) {
$list = Config::getParam('pools-cache', []);
$adapters = [];
@@ -233,6 +253,16 @@ $container->set('redis', function () {
return $redis;
});
$container->set('locks', function (Group $pools) {
return function (string $key, int $ttl, callable $callback, float $timeout = 0.0) use ($pools): mixed {
return $pools->get('lock')->use(function (\Redis $redis) use ($key, $ttl, $callback, $timeout) {
$lock = new Distributed($redis, $key, ttl: $ttl);
return $lock->withLock($callback, timeout: $timeout);
});
};
}, ['pools']);
$container->set('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
@@ -390,5 +420,3 @@ $container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
$container->set('executor', fn () => new Executor());
+93 -123
View File
@@ -5,10 +5,9 @@ use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Databases\TransactionState;
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\Message\Func as FunctionMessage;
use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
@@ -48,6 +47,7 @@ use Utopia\Locale\Locale;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
@@ -59,26 +59,18 @@ use Utopia\Validator\WhiteList;
* These resources depend (directly or transitively) on request/response
* and must be fresh for each HTTP request.
*/
return function (Container $container): void {
$container->set('utopia:graphql', function ($utopia) {
return $utopia;
}, ['utopia']);
return function (Container $context): void {
$context->set('utopia:graphql', fn ($utopia) => $utopia, ['utopia']);
$container->set('log', fn () => new Log(), []);
$context->set('log', fn () => new Log(), []);
$container->set('logger', function ($register) {
return $register->get('logger');
}, ['register']);
$context->set('logger', fn ($register) => $register->get('logger'), ['register']);
$container->set('authorization', function () {
return new Authorization();
}, []);
$context->set('authorization', fn () => new Authorization(), []);
$container->set('store', function (): Store {
return new Store();
}, []);
$context->set('store', fn (): Store => new Store(), []);
$container->set('proofForPassword', function (): Password {
$context->set('proofForPassword', function (): Password {
$hash = new Argon2();
$hash
->setMemoryCost(7168)
@@ -92,21 +84,21 @@ return function (Container $container): void {
return $password;
});
$container->set('proofForToken', function (): Token {
$context->set('proofForToken', function (): Token {
$token = new Token();
$token->setHash(new Sha());
return $token;
});
$container->set('proofForCode', function (): Code {
$context->set('proofForCode', function (): Code {
$code = new Code();
$code->setHash(new Sha());
return $code;
});
$container->set('locale', function () {
$context->set('locale', function () {
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
@@ -114,32 +106,17 @@ return function (Container $container): void {
});
// Per-request queue resources (stateful, accumulate event data during request)
$container->set('queueForDatabase', function (Publisher $publisher) {
return new EventDatabase($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('queueForRealtime', function () {
return new Realtime();
}, []);
$container->set('usage', function () {
return new UsageContext();
}, []);
$container->set('auditContext', fn () => new AuditContext(), []);
$container->set('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
$container->set('eventProcessor', function () {
return new EventProcessor();
}, []);
$container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
$context->set('queueForEvents', fn (Publisher $publisher) => new Event($publisher), ['publisher']);
$context->set('queueForWebhooks', fn (Publisher $publisher) => new Webhook($publisher), ['publisher']);
$context->set('queueForRealtime', fn () => new Realtime(), []);
$context->set('usage', fn () => new UsageContext(), []);
$context->set('auditContext', fn () => new AuditContext(), []);
$context->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
$publisher,
new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
), ['publisher']);
$context->set('eventProcessor', fn () => new EventProcessor(), []);
$context->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = new DatabasePool($pools->get('console'));
$database = new Database($adapter, $cache);
@@ -157,7 +134,7 @@ return function (Container $container): void {
return $database;
}, ['pools', 'cache', 'authorization']);
$container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
$context->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
$adapters = [];
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$adapters) {
@@ -214,7 +191,7 @@ return function (Container $container): void {
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$context->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
@@ -246,7 +223,7 @@ return function (Container $container): void {
/**
* List of allowed request hostnames for the request.
*/
$container->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
$context->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
$allowed = [...($platform['hostnames'] ?? [])];
/* Add platform configured hostnames */
@@ -290,7 +267,7 @@ return function (Container $container): void {
/**
* List of allowed request schemes for the request.
*/
$container->set('allowedSchemes', function (array $platform, Document $project) {
$context->set('allowedSchemes', function (array $platform, Document $project) {
$allowed = [...($platform['schemas'] ?? [])];
if (! $project->isEmpty() && $project->getId() !== 'console') {
@@ -310,7 +287,7 @@ return function (Container $container): void {
/**
* Whether the request origin is verified against the request hostname.
*/
$container->set('domainVerification', function (Request $request) {
$context->set('domainVerification', function (Request $request) {
$origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
$selfDomain = new Domain($request->getHostname());
$endDomain = new Domain((string) $origin);
@@ -322,7 +299,7 @@ return function (Container $container): void {
/**
* Cookie domain for the current request.
*/
$container->set('cookieDomain', function (Request $request, Document $project) {
$context->set('cookieDomain', function (Request $request, Document $project) {
$localHosts = ['localhost', 'localhost:' . $request->getPort()];
$migrationHost = System::getEnv('_APP_MIGRATION_HOST');
@@ -356,7 +333,7 @@ return function (Container $container): void {
/**
* Rule associated with a request origin.
*/
$container->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
$context->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
$domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
if (empty($domain)) {
@@ -406,7 +383,7 @@ return function (Container $container): void {
/**
* CORS service
*/
$container->set('cors', function (array $allowedHostnames) {
$context->set('cors', function (array $allowedHostnames) {
$corsConfig = Config::getParam('cors');
return new Cors(
@@ -418,23 +395,23 @@ return function (Container $container): void {
);
}, ['allowedHostnames']);
$container->set('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
if (! $devKey->isEmpty()) {
return new URL();
}
$context->set(
'originValidator',
fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
? new Origin($allowedHostnames, $allowedSchemes)
: new URL(),
['devKey', 'allowedHostnames', 'allowedSchemes']
);
return new Origin($allowedHostnames, $allowedSchemes);
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
$context->set(
'redirectValidator',
fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
? new Redirect($allowedHostnames, $allowedSchemes)
: new URL(),
['devKey', 'allowedHostnames', 'allowedSchemes']
);
$container->set('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
if (! $devKey->isEmpty()) {
return new URL();
}
return new Redirect($allowedHostnames, $allowedSchemes);
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
$container->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
$context->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
/**
* Handles user authentication and session validation.
*
@@ -605,7 +582,7 @@ return function (Container $container): void {
return $user;
}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']);
$container->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
$context->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
/** @var Appwrite\Utopia\Request $request */
/** @var Utopia\Database\Database $dbForPlatform */
/** @var Utopia\Database\Document $console */
@@ -619,7 +596,7 @@ return function (Container $container): void {
// These endpoints moved from /v1/projects/:projectId/<resource> to /v1/<resource>
// When accessed via the old alias path, extract projectId from the URI
$deprecatedProjectPathPrefix = '/v1/projects/';
$route = $utopia->match($request);
$route = $utopia->match($request)?->route;
if (!empty($route)) {
$isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) &&
!\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix);
@@ -638,7 +615,7 @@ return function (Container $container): void {
return $project;
}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']);
$container->set('session', function (User $user, Store $store, Token $proofForToken) {
$context->set('session', function (User $user, Store $store, Token $proofForToken) {
if ($user->isEmpty()) {
return;
}
@@ -659,7 +636,7 @@ return function (Container $container): void {
return;
}, ['user', 'store', 'proofForToken']);
$container->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) {
$context->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
@@ -715,7 +692,7 @@ return function (Container $container): void {
* Accounts can be created in many ways beyond `createAccount`
* (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here.
*/
$eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
$eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
// Only trigger events for user creation with the database listener.
if ($document->getCollection() !== 'users') {
return;
@@ -727,9 +704,15 @@ return function (Container $container): void {
->setPayload($response->output($document, Response::MODEL_USER));
// Trigger functions, webhooks, and realtime events
$queueForFunctions
->from($queueForEvents)
->trigger();
$publisherForFunctions->enqueue(FunctionMessage::fromEvent(
event: $queueForEvents->getEvent(),
params: $queueForEvents->getParams(),
project: $queueForEvents->getProject(),
user: $queueForEvents->getUser(),
userId: $queueForEvents->getUserId(),
payload: $queueForEvents->getPayload(),
platform: $queueForEvents->getPlatform(),
));
/** Trigger webhooks events only if a project has them enabled */
if (! empty($project->getAttribute('webhooks'))) {
@@ -909,7 +892,6 @@ return function (Container $container): void {
// Clone the queues, to prevent events triggered by the database listener
// from overwriting the events that are supposed to be triggered in the shutdown hook.
$queueForEventsClone = new Event($publisher);
$queueForFunctions = new Func($publisherFunctions);
$queueForWebhooks = new Webhook($publisherWebhooks);
$queueForRealtime = new Realtime();
@@ -924,7 +906,7 @@ return function (Container $container): void {
$document,
$response,
$queueForEventsClone->from($queueForEvents),
$queueForFunctions->from($queueForEvents),
$publisherForFunctions,
$queueForWebhooks->from($queueForEvents),
$queueForRealtime->from($queueForEvents)
))
@@ -933,9 +915,9 @@ return function (Container $container): void {
->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database));
return $database;
}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']);
}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'publisherForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']);
$container->set('schema', function ($utopia, $dbForProject, $authorization) {
$context->set('schema', function ($utopia, $dbForProject, $authorization) {
$complexity = function (int $complexity, array $args) {
$queries = Query::parseQueries($args['queries'] ?? []);
@@ -1022,13 +1004,9 @@ return function (Container $container): void {
);
}, ['utopia', 'dbForProject', 'authorization']);
$container->set('audit', function ($dbForProject) {
$adapter = new AdapterDatabase($dbForProject);
$context->set('audit', fn ($dbForProject) => new Audit(new AdapterDatabase($dbForProject)), ['dbForProject']);
return new Audit($adapter);
}, ['dbForProject']);
$container->set('mode', function ($request, Document $project) {
$context->set('mode', function ($request, Document $project) {
/** @var Appwrite\Utopia\Request $request */
/**
@@ -1046,7 +1024,7 @@ return function (Container $container): void {
return $mode;
}, ['request', 'project']);
$container->set('requestTimestamp', function ($request) {
$context->set('requestTimestamp', function ($request) {
// TODO: Move this to the Request class itself
$timestampHeader = $request->getHeader('x-appwrite-timestamp');
$requestTimestamp = null;
@@ -1061,7 +1039,7 @@ return function (Container $container): void {
return $requestTimestamp;
}, ['request']);
$container->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
$context->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
$devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
// Check if given key match project's development keys
@@ -1110,12 +1088,12 @@ return function (Container $container): void {
return $key;
}, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']);
$container->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
$context->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
$teamInternalId = '';
if ($project->getId() !== 'console') {
$teamInternalId = $project->getAttribute('teamInternalId', '');
} else {
$route = $utopia->match($request);
$route = $utopia->match($request)?->route;
$path = ! empty($route) ? $route->getPath() : $request->getURI();
$orgHeader = $request->getHeader('x-appwrite-organization', '');
if (str_starts_with($path, '/v1/projects/:projectId')) {
@@ -1153,7 +1131,7 @@ return function (Container $container): void {
return $team;
}, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']);
$container->set('previewHostname', function (Request $request, ?Key $apiKey) {
$context->set('previewHostname', function (Request $request, ?Key $apiKey) {
$allowed = false;
if (Http::isDevelopment()) {
@@ -1172,7 +1150,7 @@ return function (Container $container): void {
return '';
}, ['request', 'apiKey']);
$container->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
$context->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
$key = $request->getHeader('x-appwrite-key');
if (empty($key)) {
@@ -1206,7 +1184,7 @@ return function (Container $container): void {
return $key;
}, ['request', 'project', 'team', 'user']);
$container->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
$context->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
$tokenJWT = $request->getParam('token');
if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication
@@ -1273,10 +1251,10 @@ return function (Container $container): void {
return new Document([]);
}, ['project', 'dbForProject', 'request', 'authorization']);
$container->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
$context->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database {
$databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
$databaseDSN = $database->getAttribute('database') ?: $project->getAttribute('database', '');
$databaseType = $database->getAttribute('type', '');
try {
@@ -1435,35 +1413,27 @@ return function (Container $container): void {
}, ['pools', 'cache', 'project', 'request', 'usage', 'authorization']);
$container->set('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) {
return new TransactionState($dbForProject, $authorization, $getDatabasesDB);
}, ['dbForProject', 'authorization', 'getDatabasesDB']);
$context->set(
'transactionState',
fn (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) => new TransactionState($dbForProject, $authorization, $getDatabasesDB),
['dbForProject', 'authorization', 'getDatabasesDB']
);
$container->set('executionsRetentionCount', function (Document $project, array $plan) {
if ($project->getId() === 'console' || empty($plan)) {
return 0;
}
$context->set(
'executionsRetentionCount',
fn (Document $project, array $plan) => ($project->getId() === 'console' || empty($plan))
? 0
: (int) ($plan['executionsRetentionCount'] ?? 100),
['project', 'plan']
);
return (int) ($plan['executionsRetentionCount'] ?? 100);
}, ['project', 'plan']);
$context->set('deviceForFiles', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())), ['project', 'telemetry']);
$context->set('deviceForSites', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())), ['project', 'telemetry']);
$context->set('deviceForMigrations', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())), ['project', 'telemetry']);
$context->set('deviceForFunctions', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())), ['project', 'telemetry']);
$context->set('deviceForBuilds', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())), ['project', 'telemetry']);
$container->set('deviceForFiles', function ($project, Telemetry $telemetry) {
return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForSites', function ($project, Telemetry $telemetry) {
return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForMigrations', function ($project, Telemetry $telemetry) {
return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForFunctions', function ($project, Telemetry $telemetry) {
return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('deviceForBuilds', function ($project, Telemetry $telemetry) {
return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
$container->set('embeddingAgent', function ($register) {
$context->set('embeddingAgent', function ($register) {
$adapter = new Ollama();
$adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed'));
$adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000'));
+20 -1
View File
@@ -3,11 +3,30 @@
use Utopia\Span\Exporter;
use Utopia\Span\Span;
use Utopia\Span\Storage;
use Utopia\System\System;
Span::setStorage(new Storage\Coroutine());
Span::addExporter(new Exporter\Pretty(), function (Span $span): bool {
// Resolve trace filters once at boot to avoid repeated env lookups per span.
$traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
$traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
$traceEnabled = $traceProjectId !== '' || $traceFunctionId !== '';
Span::addExporter(new Exporter\Pretty(), function (Span $span) use ($traceEnabled, $traceProjectId, $traceFunctionId): bool {
if (\str_starts_with($span->getAction(), 'listener.')) {
return $span->getError() !== null;
}
// Selective tracing: when _APP_TRACE_PROJECT_ID / _APP_TRACE_FUNCTION_ID are set,
// only export spans tagged with matching project.id / function.id.
if ($traceEnabled) {
if ($traceProjectId !== '' && $span->get('project.id') !== $traceProjectId) {
return false;
}
if ($traceFunctionId !== '' && $span->get('function.id') !== $traceFunctionId) {
return false;
}
}
return true;
});
-15
View File
@@ -1,9 +1,6 @@
<?php
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Usage\Context;
@@ -327,14 +324,6 @@ return function (Container $container): void {
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('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
$container->set('queueForEvents', function (Publisher $publisher) {
return new Event($publisher);
}, ['publisher']);
@@ -343,10 +332,6 @@ return function (Container $container): void {
return new Webhook($publisher);
}, ['publisher']);
$container->set('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
$container->set('queueForRealtime', function () {
return new Realtime();
}, []);
+319 -335
View File
@@ -1,10 +1,20 @@
<?php
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime as QueueRealtime;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Network\Validator\Origin;
use Appwrite\Presences\State as PresenceState;
use Appwrite\PubSub\Adapter\Pool as PubSubPool;
use Appwrite\Realtime\Message\Dispatcher as MessageDispatcher;
use Appwrite\Realtime\Message\Handlers\Authentication as AuthenticationHandler;
use Appwrite\Realtime\Message\Handlers\Ping as PingHandler;
use Appwrite\Realtime\Message\Handlers\Presence as PresenceHandler;
use Appwrite\Realtime\Message\Handlers\Subscribe as SubscribeHandler;
use Appwrite\Realtime\Message\Handlers\Unsubscribe as UnsubscribeHandler;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -16,9 +26,6 @@ use Swoole\Table;
use Swoole\Timer;
use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\Auth\Hashes\Sha;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
@@ -28,7 +35,9 @@ use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Authorization as AuthorizationException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
@@ -37,6 +46,8 @@ use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Queue;
use Utopia\Registry\Registry;
use Utopia\Span\Span;
use Utopia\System\System;
@@ -67,6 +78,38 @@ set_exception_handler(function (\Throwable $e) {
));
});
global $container;
if (!$container->has('pools')) {
$container->set('pools', function ($register) {
return $register->get('pools');
}, ['register']);
}
if (!$container->has('publisherForUsage')) {
$container->set('publisherForUsage', function (Group $pools): UsagePublisher {
$statsUsageConnection = System::getEnv('_APP_CONNECTIONS_QUEUE_STATS_USAGE', '');
$publisherPoolName = 'publisher';
if (!empty($statsUsageConnection)) {
try {
$pools->get('publisher_' . $statsUsageConnection);
$publisherPoolName = 'publisher_' . $statsUsageConnection;
} catch (Throwable) {
// Fallback to default publisher pool when custom one is unavailable.
}
}
return new UsagePublisher(
new BrokerPool(publisher: $pools->get($publisherPoolName)),
new Queue(System::getEnv(
'_APP_STATS_USAGE_QUEUE_NAME',
QueueEvent::STATS_USAGE_QUEUE_NAME
))
);
}, ['pools']);
}
// Allows overriding
if (!function_exists('getConsoleDB')) {
function getConsoleDB(): Database
@@ -234,6 +277,7 @@ if (!function_exists('getRealtime')) {
}
}
if (!function_exists('getTelemetry')) {
function getTelemetry(int $workerId): Utopia\Telemetry\Adapter
{
@@ -247,18 +291,58 @@ if (!function_exists('getTelemetry')) {
}
}
if (!function_exists('getQueueForEvents')) {
function getQueueForEvents(): QueueEvent
{
$ctx = Coroutine::getContext();
if (!isset($ctx['queueForEvents'])) {
global $register;
/** @var Group $pools */
$pools = $register->get('pools');
$ctx['queueForEvents'] = new QueueEvent(new BrokerPool(
publisher: $pools->get('publisher')
));
}
return $ctx['queueForEvents'];
}
}
if (!function_exists('getQueueForRealtime')) {
function getQueueForRealtime(): QueueRealtime
{
$ctx = Coroutine::getContext();
if (!isset($ctx['queueForRealtime'])) {
$ctx['queueForRealtime'] = new QueueRealtime();
}
return $ctx['queueForRealtime'];
}
}
if (!function_exists('triggerStats')) {
function triggerStats(array $event, string $projectId): void
{
}
}
global $container;
$container->set('pools', function ($register) {
return $register->get('pools');
}, ['register']);
if (!function_exists('checkForProjectUsage')) {
function checkForProjectUsage(Document $project): void
{
}
}
$realtime = getRealtime();
$presenceState = new PresenceState();
$messageDispatcher = (new MessageDispatcher())
->addHandler(new PingHandler())
->addHandler(new AuthenticationHandler())
->addHandler(new SubscribeHandler())
->addHandler(new UnsubscribeHandler())
->addHandler(new PresenceHandler());
/**
* Table for statistics across all workers.
@@ -292,7 +376,16 @@ if (!function_exists('logError')) {
$logger = $register->get('realtimeLogger');
if ($logger && !$error instanceof Exception) {
// Match HTTP semantics (app/controllers/general.php): AppwriteException uses its
// configured publish flag; everything else publishes only for code 0 or >= 500.
// Without this, expected client errors (e.g. Utopia DB Authorization) hit Sentry.
if ($error instanceof AppwriteException) {
$publish = $error->isPublishable();
} else {
$publish = $error->getCode() === 0 || $error->getCode() >= 500;
}
if ($logger && $publish) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$log = new Log();
@@ -612,6 +705,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
}
}
// Strip deleted presences from in-memory connection state so onClose doesn't
// re-fire delete events for rows already removed via HTTP DELETE.
$deletedPresenceId = Realtime::extractDeletedPresenceId($event);
if ($deletedPresenceId !== null) {
$realtime->removePresenceFromConnections(
(string) ($event['project'] ?? ''),
$deletedPresenceId,
);
}
$receivers = $realtime->getSubscribers($event);
if (System::getEnv('_APP_ENV', 'production') === 'development' && !empty($receivers)) {
@@ -728,8 +831,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$success = false;
Span::init('realtime.open');
Span::add('realtime.connectionId', $connection);
Span::add('realtime.inboundBytes', $rawSize);
Span::add('realtime.connection.id', $connection);
Span::add('realtime.inbound_bytes', $rawSize);
if (!empty($request->getOrigin())) {
Span::add('realtime.origin', $request->getOrigin());
}
@@ -898,6 +1001,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$success = true;
} catch (Throwable $th) {
Span::error($th);
// Convert known Utopia DB exceptions to AppwriteException so isPublishable()
// suppresses expected client errors (permission denied, query timeout) from Sentry.
if ($th instanceof AuthorizationException) {
$th = new AppwriteException(AppwriteException::USER_UNAUTHORIZED, previous: $th);
} elseif ($th instanceof TimeoutException) {
$th = new AppwriteException(AppwriteException::DATABASE_TIMEOUT, previous: $th);
}
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
// Handle SQL error code is 'HY000'
@@ -933,56 +1046,79 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
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);
Span::add('realtime.response_code', $responseCode);
Span::add('realtime.subscription_mode', $subscriptionMode);
Span::add('realtime.channel_count', $channelCount);
Span::add('realtime.subscription_count', $subscriptionCount);
Span::add('realtime.outbound_bytes', $outboundBytes);
if (!empty($project?->getId())) {
Span::add('realtime.projectId', $project->getId());
Span::add('project.id', $project->getId());
}
if (!empty($logUser?->getId())) {
Span::add('realtime.userId', $logUser->getId());
Span::add('user.id', $logUser->getId());
}
Span::current()?->finish();
}
});
$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) {
$server->onMessage(function (int $connection, string $message) use ($container, $server, $realtime, $containerId, $register, $presenceState, $messageDispatcher) {
$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);
Span::add('realtime.connection.id', $connection);
Span::add('realtime.inbound_bytes', $rawSize);
Span::add('realtime.container.id', $containerId);
try {
$response = new Response(new SwooleResponse());
// Get authorization from connection (stored during onOpen)
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
if ($authorization === null) {
$authorization = new Authorization();
// Build a fresh Authorization per message. The connection-scoped instance is shared
// across coroutines, and `Authorization::skip()` toggles instance state — concurrent
// messages on the same connection (e.g. `authentication` + `presence` sent back-to-back)
// would interleave skip/restore and leak permission checks into supposedly-skipped lookups.
$authorization = new Authorization();
$connectionAuthorization = $realtime->connections[$connection]['authorization'] ?? null;
if ($connectionAuthorization !== null) {
foreach ($connectionAuthorization->getRoles() as $role) {
$authorization->addRole($role);
}
}
$connectionRoles = $realtime->connections[$connection]['roles'] ?? [];
foreach ($connectionRoles as $role) {
if ($authorization->hasRole($role)) {
continue;
}
$authorization->addRole($role);
}
$database = getConsoleDB();
$database->setAuthorization($authorization);
if (!empty($projectId) && $projectId !== 'console') {
$project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
// Negative-cache race: if any prior code path queried projects:$projectId
// before this project existed (e.g. a router probe during connection
// setup), the Database's shared cache may hold an empty result. Try the
// cached read first, and only purge/retry when the first lookup reports
// not-found so the shared cache remains effective for normal traffic.
try {
$project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
} catch (AppwriteException $e) {
if ($e->getCode() !== 404) {
throw $e;
}
$database->purgeCachedDocument('projects', $projectId);
$project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
}
$database = getProjectDB($project);
$database->setAuthorization($authorization);
@@ -990,6 +1126,10 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$project = null;
}
if ($project !== null) {
checkForProjectUsage($project);
}
/*
* Abuse Check
*
@@ -1008,6 +1148,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
}
// Record realtime inbound bytes for this project
// not making this a part of the dispatcher as we need to get the inbound bytes as well even if we dont enter the dispatcher
if ($project !== null && !$project->isEmpty()) {
triggerStats([
METRIC_REALTIME_INBOUND => $rawSize,
@@ -1026,300 +1167,54 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
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.');
// Child of the global container: per-message values like $connection and $project
// live on this scope so concurrent message coroutines don't clobber each other,
// while globally-registered services (pools, ...) remain reachable via the parent.
$messageContainer = new Container($container);
$messageContainer->set('connectionId', fn () => $connection);
$messageContainer->set('server', fn () => $server);
$messageContainer->set('realtime', fn () => $realtime);
$messageContainer->set('register', fn () => $register);
$messageContainer->set('response', fn () => $response);
$messageContainer->set('presenceState', fn () => $presenceState);
$messageContainer->set('database', fn () => $database);
$messageContainer->set('authorization', fn () => $authorization);
$messageContainer->set('project', fn () => $project);
$messageContainer->set('projectId', fn () => $projectId);
$messageContainer->set('queueForEvents', fn () => getQueueForEvents());
$messageContainer->set('queueForRealtime', fn () => getQueueForRealtime());
$responsePayload = $messageDispatcher->dispatch($messageContainer, $message);
if ($responsePayload !== null) {
$responseJson = json_encode($responsePayload);
if ($responseJson === false) {
throw new \RuntimeException(
'Failed to encode realtime response payload: ' . json_last_error_msg()
);
}
$server->send([$connection], $responseJson);
$bytes = \strlen($responseJson);
$outboundBytes += $bytes;
if ($project !== null && !$project->isEmpty()) {
triggerStats([METRIC_REALTIME_OUTBOUND => $bytes], $project->getId());
}
}
switch ($message['type']) {
case 'ping':
$pongPayloadJson = json_encode([
'type' => 'pong'
]);
$server->send([$connection], $pongPayloadJson);
$outboundBytes += \strlen($pongPayloadJson);
if ($project !== null && !$project->isEmpty()) {
$pongOutboundBytes = \strlen($pongPayloadJson);
if ($pongOutboundBytes > 0) {
triggerStats([
METRIC_REALTIME_OUTBOUND => $pongOutboundBytes,
], $project->getId());
}
}
break;
case 'authentication':
if (!array_key_exists('session', $message['data'])) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
}
$store = new Store();
$store->decode($message['data']['session']);
/** @var User $user */
$user = $database->getDocument('users', $store->getProperty('id', ''));
/**
* TODO:
* Moving forward, we should try to use our dependency injection container
* to inject the proof for token.
* This way we will have one source of truth for the proof for token.
*/
$proofForToken = new Token();
$proofForToken->setHash(new Sha());
if (
empty($user->getId()) // Check a document has been found in the DB
|| !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) // Validate user has valid login token
) {
// cookie not valid
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.');
}
$roles = $user->getRoles($database->getAuthorization());
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
// Capture the pre-auth userId so we can rebind any account channels
// that were stored under it (e.g. guest who subscribed to `account`
// and now authenticates). unsubscribe() below clears the connection
// entry, so we must read it first.
$previousUserId = $realtime->connections[$connection]['userId'] ?? '';
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$meta = $realtime->getSubscriptionMetadata($connection);
$realtime->unsubscribe($connection);
if (!empty($projectId)) {
foreach ($meta as $subscriptionId => $subscription) {
$queries = Query::parseQueries($subscription['queries'] ?? []);
$channels = Realtime::rebindAccountChannels(
$subscription['channels'] ?? [],
$previousUserId,
$user->getId()
);
$realtime->subscribe(
$projectId,
$connection,
$subscriptionId,
$roles,
$channels,
$queries,
$user->getId()
);
}
}
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'));
}
$user = $response->output($user, Response::MODEL_ACCOUNT);
$authResponsePayloadJson = json_encode([
'type' => 'response',
'data' => [
'to' => 'authentication',
'success' => true,
'user' => $user
]
]);
$server->send([$connection], $authResponsePayloadJson);
$outboundBytes += \strlen($authResponsePayloadJson);
if ($project !== null && !$project->isEmpty()) {
$authOutboundBytes = \strlen($authResponsePayloadJson);
if ($authOutboundBytes > 0) {
triggerStats([
METRIC_REALTIME_OUTBOUND => $authOutboundBytes,
], $project->getId());
}
}
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) {
Span::error($th);
// Convert known Utopia DB exceptions to AppwriteException so isPublishable()
// suppresses expected client errors (permission denied, query timeout) from Sentry.
if ($th instanceof AuthorizationException) {
$th = new AppwriteException(AppwriteException::USER_UNAUTHORIZED, previous: $th);
} elseif ($th instanceof TimeoutException) {
$th = new AppwriteException(AppwriteException::DATABASE_TIMEOUT, previous: $th);
}
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$code = $th->getCode();
if (!is_int($code)) {
@@ -1349,30 +1244,25 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
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::add('realtime.response_code', $responseCode);
Span::add('realtime.outbound_bytes', $outboundBytes);
Span::add('project.id', $project?->getId() ?? $projectId);
Span::add('user.id', $realtime->connections[$connection]['userId'] ?? null);
Span::add('realtime.message_type', $messageType);
Span::current()?->finish();
}
});
$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
$server->onClose(function (int $connection) use ($realtime, $stats, $register, $container, $presenceState) {
$projectId = null;
$userId = null;
$subscriptionsBeforeClose = 0;
$success = false;
Span::init('realtime.close');
Span::add('realtime.connectionId', $connection);
Span::add('realtime.connection.id', $connection);
if (array_key_exists($connection, $realtime->connections)) {
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
@@ -1390,6 +1280,100 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
}
$projectId = $realtime->connections[$connection]['projectId'];
/** @var array<string, Document> $presencesById */
$presencesById = $realtime->connections[$connection]['presences'] ?? [];
if (
!empty($presencesById)
&& $projectId !== 'console'
) {
go(function () use ($presencesById, $projectId, $userId, $container, $presenceState): void {
// Fresh span: the parent realtime.close span finishes before this coroutine
Span::init('realtime.close.presenceCleanup');
Span::add('realtime.projectId', $projectId);
Span::add('realtime.presenceCount', \count($presencesById));
try {
$dbForPlatform = getConsoleDB();
$project = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if ($project->isEmpty()) {
return;
}
$presenceIds = \array_keys($presencesById);
$presences = \array_values($presencesById);
$dbForProject = getProjectDB($project);
$user = new User([]);
if (!empty($userId)) {
try {
$fetched = $dbForProject->getAuthorization()->skip(
fn () => $dbForProject->getDocument('users', $userId)
);
if (!$fetched->isEmpty()) {
$user = new User($fetched->getArrayCopy());
}
} catch (Throwable) {
// Fall back to empty User if lookup fails.
}
}
/** @var UsagePublisher $publisherForUsage */
$publisherForUsage = $container->get('publisherForUsage');
/** @var array<string, true> $deletedIds */
$deletedIds = [];
try {
$deletionCount = $dbForProject->getAuthorization()->skip(
function () use ($dbForProject, $presenceIds, &$deletedIds): int {
return $dbForProject->deleteDocuments(
'presenceLogs',
[Query::equal('$id', $presenceIds)],
onNext: function (Document $deleted) use (&$deletedIds): void {
$deletedIds[$deleted->getId()] = true;
},
);
}
);
$presenceState->triggerUsage($publisherForUsage, $project, -$deletionCount);
} catch (Throwable $th) {
Span::error($th);
logError($th, 'realtimeOnClosePresenceDeletion', tags: [
'projectId' => $projectId,
'presences' => \count($presences)
]);
}
$queueForEvents = getQueueForEvents();
$queueForRealtime = getQueueForRealtime();
foreach ($presences as $presence) {
if (!isset($deletedIds[$presence->getId()])) {
continue;
}
try {
$presenceState->triggerEvent(
$queueForEvents,
$queueForRealtime,
$project,
$user,
'presences.[presenceId].delete',
$presence,
);
} catch (Throwable) {
// Swallow errors to avoid breaking disconnect cleanup
}
}
} catch (Throwable $th) {
Span::error($th);
logError($th, 'realtimeOnClosePresenceCleanup', tags: [
'projectId' => $projectId,
]);
} finally {
Span::current()?->finish();
}
});
}
triggerStats([
METRIC_REALTIME_CONNECTIONS => -1,
@@ -1411,12 +1395,12 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
Span::add('realtime.success', $success);
if (!empty($projectId)) {
Span::add('realtime.projectId', $projectId);
Span::add('project.id', $projectId);
}
if (!empty($userId)) {
Span::add('realtime.userId', $userId);
Span::add('user.id', $userId);
}
Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose);
Span::add('realtime.subscriptions_before_close', $subscriptionsBeforeClose);
Span::current()?->finish();
}
+9 -1
View File
@@ -16,6 +16,7 @@ use Utopia\Pools\Group;
use Utopia\Queue\Adapter\Swoole;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Server;
use Utopia\Span\Span;
use Utopia\System\System;
Runtime::enableCoroutine();
@@ -91,8 +92,13 @@ $adapter = new Swoole(
$worker = new Server($adapter, $container);
try {
$worker->init()->action(function () use ($worker, $registerWorkerMessageResources) {
$worker->init()->action(function () use ($worker, $registerWorkerMessageResources, $queueName) {
$registerWorkerMessageResources($worker->getContainer());
Span::init("worker.{$queueName}");
});
$worker->shutdown()->action(function () {
Span::current()?->finish();
});
$container->set('bus', function ($register) use ($worker) {
@@ -120,6 +126,8 @@ $worker
->action(function (Throwable $error, ?Logger $logger, Log $log, Document $project, Authorization $authorization) use ($queueName) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
Span::error($error);
if ($logger) {
$log->setNamespace('appwrite-worker');
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
+9 -8
View File
@@ -54,28 +54,29 @@
"utopia-php/abuse": "1.3.*",
"utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
"utopia-php/audit": "2.2.*",
"utopia-php/audit": "2.3.*",
"utopia-php/auth": "0.5.*",
"utopia-php/cache": "1.0.*",
"utopia-php/cache": "^3.0",
"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/detector": "0.2.*",
"utopia-php/domains": "1.*",
"utopia-php/emails": "0.6.*",
"utopia-php/dns": "1.6.*",
"utopia-php/domains": "^2.1",
"utopia-php/emails": "0.7.*",
"utopia-php/dns": "1.7.*",
"utopia-php/dsn": "0.2.1",
"utopia-php/http": "0.34.*",
"utopia-php/http": "2.0.0-rc2",
"utopia-php/fetch": "^1.1",
"utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
"utopia-php/lock": "0.2.*",
"utopia-php/logger": "0.8.*",
"utopia-php/messaging": "0.22.*",
"utopia-php/migration": "1.*",
"utopia-php/platform": "0.13.*",
"utopia-php/platform": "1.0.0-rc2",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
"utopia-php/preloader": "0.2.*",
@@ -85,7 +86,7 @@
"utopia-php/storage": "2.*",
"utopia-php/system": "0.10.*",
"utopia-php/telemetry": "0.2.*",
"utopia-php/vcs": "3.*",
"utopia-php/vcs": "4.*",
"utopia-php/websocket": "1.0.*",
"matomo/device-detector": "6.4.*",
"dragonmantank/cron-expression": "3.4.*",
Generated
+357 -262
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
Delete an analyzer report by its unique ID. Nested insights and CTA metadata are removed asynchronously by the deletes worker.
+1
View File
@@ -0,0 +1 @@
Get an insight by its unique ID, scoped to its parent report.
+1
View File
@@ -0,0 +1 @@
Get an analyzer report by its unique ID. The response includes the report's metadata and the nested insights it produced.
+1
View File
@@ -0,0 +1 @@
List the insights produced under a single analyzer report. You can use the query params to filter your results further.
+1
View File
@@ -0,0 +1 @@
Get a list of all the project's analyzer reports. You can use the query params to filter your results.
+1
View File
@@ -0,0 +1 @@
Delete a presence log by its unique ID.
+1
View File
@@ -0,0 +1 @@
Get presence usage metrics, including the current total of online users and historical online user counts for the selected time range.
+1
View File
@@ -0,0 +1 @@
Get a presence log by its unique ID. Entries whose `expiresAt` is in the past are treated as not found.
+1
View File
@@ -0,0 +1 @@
List presence logs. Expired entries are filtered out automatically.
+1
View File
@@ -0,0 +1 @@
Update a presence log by its unique ID. Using the patch method you can pass only specific fields that will get updated.
+1
View File
@@ -0,0 +1 @@
Create or update a presence log by its user ID.
-1
View File
@@ -1 +0,0 @@
Create a new JWT token. This token can be used to authenticate users with custom scopes and expiration time.
@@ -1 +0,0 @@
Send a test email to verify SMTP configuration.
-1
View File
@@ -1 +0,0 @@
Create a new project. You can create a maximum of 100 projects per account.
@@ -1 +0,0 @@
Reset a custom email template to its default value. This endpoint removes any custom content and restores the template to its original state.
-1
View File
@@ -1 +0,0 @@
Delete a project by its unique ID.
@@ -1 +0,0 @@
Get a custom email template for the specified locale and type. This endpoint returns the template content, subject, and other configuration details.
-1
View File
@@ -1 +0,0 @@
Get a project by its unique ID. This endpoint allows you to retrieve the project's details, including its name, description, team, region, and other metadata.
@@ -1 +0,0 @@
Update the status of a specific authentication method. Use this endpoint to enable or disable different authentication methods such as email, magic urls or sms in your project.
@@ -1 +0,0 @@
Update a custom email template for the specified locale and type. Use this endpoint to modify the content of your email templates.
@@ -1 +0,0 @@
Update the list of mock phone numbers for testing. Use these numbers to bypass SMS verification in development.
@@ -1 +0,0 @@
Update the OAuth2 provider configurations. Use this endpoint to set up or update the OAuth2 provider credentials or enable/disable providers.
-1
View File
@@ -1 +0,0 @@
Update the SMTP configuration for your project. Use this endpoint to configure your project's SMTP provider with your custom settings for sending transactional emails.
-1
View File
@@ -1 +0,0 @@
Update a project by its unique ID.
+116
View File
@@ -0,0 +1,116 @@
## Getting Started
Before you begin, create an Appwrite project and add a Unity platform in your Appwrite Console.
This SDK requires the following Unity packages and libraries:
- [**UniTask**](https://github.com/Cysharp/UniTask): For async/await support in Unity.
- [**NativeWebSocket**](https://github.com/endel/NativeWebSocket): For WebSocket realtime subscriptions.
- **System.Text.Json**: For JSON serialization, provided as a DLL in the project.
After installing the SDK, open **Appwrite → Setup Assistant** in Unity and install the required dependencies.
### Configure the SDK
Create an Appwrite configuration using the **QuickStart** window in the **Appwrite Setup Assistant**, or through **Appwrite → Create Configuration**.
### Using AppwriteManager
```csharp
[SerializeField] private AppwriteConfig config;
private AppwriteManager _manager;
private async UniTask ExampleWithManager()
{
_manager = AppwriteManager.Instance ?? new GameObject("AppwriteManager").AddComponent<AppwriteManager>();
_manager.SetConfig(config);
var success = await _manager.Initialize(needRealtime: true);
if (!success)
{
Debug.LogError("Failed to initialize AppwriteManager");
return;
}
var client = _manager.Client;
var pingResult = await client.Ping();
Debug.Log($"Ping result: {pingResult}");
var realtime = _manager.Realtime;
var subscription = realtime.Subscribe(
new[] { "databases.*.collections.*.documents" },
response =>
{
var eventName = response.Events != null && response.Events.Length > 0
? response.Events[0]
: "unknown";
Debug.Log($"Realtime event: {eventName}");
}
);
// Keep a reference to close the subscription when your MonoBehaviour is destroyed.
// subscription.Close();
}
```
### Using Client directly
```csharp
private async UniTask ExampleWithDirectClient()
{
var client = Client.From(
projectId: "<PROJECT_ID>",
endpoint: "https://<REGION>.cloud.appwrite.io/v1",
endpointRealtime: "wss://<REGION>.cloud.appwrite.io/v1");
var pingResult = await client.Ping();
Debug.Log($"Direct client ping: {pingResult}");
}
```
You can also create authenticated clients with `Client.FromSession`, `Client.FromDevKey`, or `Client.FromImpersonation` when those authentication flows are needed.
### Error handling
```csharp
try
{
var result = await client.Ping();
}
catch (AppwriteException ex)
{
Debug.LogError($"Appwrite Error: {ex.Message}");
Debug.LogError($"Status Code: {ex.Code}");
Debug.LogError($"Response: {ex.Response}");
}
```
## Preparing Models for Databases API
When working with the Databases API in Unity, models should be prepared for serialization using the System.Text.Json library. System.Text.Json uses CLR property names by default unless a naming policy is configured. If your project or SDK configuration serializes property names differently from your Appwrite collection attributes, this can cause errors due to mismatches between serialized property names and actual attribute names in your collection.
To avoid this, add the `JsonPropertyName` attribute to each property in your model class to match the attribute name in Appwrite:
```csharp
using System.Text.Json.Serialization;
public class TestModel
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("release_date")]
public System.DateTime ReleaseDate { get; set; }
}
```
The `JsonPropertyName` attribute ensures your data object is serialized with the correct attribute names for Appwrite databases.
### Learn more
You can use the following resources to learn more and get help:
- 🚀 [Getting Started Tutorial](https://appwrite.io/docs/getting-started-for-client)
- 📜 [Appwrite Docs](https://appwrite.io/docs)
- 💬 [Discord Community](https://appwrite.io/discord)
- 🧰 [Appwrite SDK Generator](https://github.com/appwrite/sdk-generator)
+3
View File
@@ -0,0 +1,3 @@
The Advisor service provides read access to analyzer reports and their nested insights for a project.
Use the reports endpoints to list and fetch analyzer runs, then use the insights endpoints to inspect individual findings attached to a report.
+1
View File
@@ -38,6 +38,7 @@
<directory>./tests/e2e/Services/Messaging</directory>
<directory>./tests/e2e/Services/Migrations</directory>
<directory>./tests/e2e/Services/Project</directory>
<directory>./tests/e2e/Services/Advisor</directory>
<file>./tests/e2e/Services/Functions/FunctionsBase.php</file>
<file>./tests/e2e/Services/Functions/FunctionsCustomServerTest.php</file>
<file>./tests/e2e/Services/Functions/FunctionsCustomClientTest.php</file>
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Appwrite\Advisor\Validator;
use Utopia\Validator;
class CTAs extends Validator
{
public const MAX_COUNT_DEFAULT = 16;
protected string $message = 'Value must be an array of CTA descriptors. Each entry must define `label`, `service`, `method`, and an optional `params` object.';
protected array $allowedServices;
protected array $allowedMethods;
public function __construct(
protected int $maxCount = self::MAX_COUNT_DEFAULT,
?array $allowedServices = null,
?array $allowedMethods = null,
) {
$this->allowedServices = $allowedServices ?? ADVISOR_CTA_SERVICES;
$this->allowedMethods = $allowedMethods ?? ADVISOR_CTA_METHODS;
}
public function getDescription(): string
{
return $this->message;
}
public function isArray(): bool
{
return true;
}
public function getType(): string
{
return self::TYPE_ARRAY;
}
public function isValid($value): bool
{
if (!\is_array($value)) {
return false;
}
if (\count($value) > $this->maxCount) {
$this->message = "A maximum of {$this->maxCount} CTAs are allowed per insight.";
return false;
}
foreach ($value as $entry) {
if (!\is_array($entry)) {
return false;
}
$maxLengths = ['label' => 256, 'service' => 64, 'method' => 64];
foreach ($maxLengths as $required => $maxLength) {
if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') {
return false;
}
if (\strlen($entry[$required]) > $maxLength) {
$this->message = "CTA `{$required}` must not exceed {$maxLength} characters.";
return false;
}
}
if (!empty($this->allowedServices) && !\in_array($entry['service'], $this->allowedServices, true)) {
$this->message = "CTA `service` must be one of: " . \implode(', ', $this->allowedServices) . '.';
return false;
}
if (!empty($this->allowedMethods) && !\in_array($entry['method'], $this->allowedMethods, true)) {
$this->message = "CTA `method` must be one of: " . \implode(', ', $this->allowedMethods) . '.';
return false;
}
if (isset($entry['params']) && !\is_array($entry['params']) && !\is_object($entry['params'])) {
return false;
}
}
return true;
}
}
+6 -14
View File
@@ -8,7 +8,6 @@ use Appwrite\Event\Publisher\Execution as ExecutionPublisher;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
use Utopia\Span\Span;
use Utopia\System\System;
class Log extends Listener
{
@@ -34,20 +33,13 @@ class Log extends Listener
{
$project = new Document($event->project);
$execution = new Document($event->execution);
if ($execution->getAttribute('resourceType', '') === 'functions') {
$traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
$traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
$resourceId = $execution->getAttribute('resourceId', '');
if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $resourceId === $traceFunctionId) {
Span::init('execution.trace.v1_executions_enqueue');
Span::add('datetime', gmdate('c'));
Span::add('projectId', $project->getId());
Span::add('functionId', $resourceId);
Span::add('executionId', $execution->getId());
Span::add('deploymentId', $execution->getAttribute('deploymentId', ''));
Span::add('status', $execution->getAttribute('status', ''));
Span::current()?->finish();
}
Span::add('project.id', $project->getId());
Span::add('function.id', $execution->getAttribute('resourceId', ''));
Span::add('execution.id', $execution->getId());
Span::add('deployment.id', $execution->getAttribute('deploymentId', ''));
Span::add('execution.status', $execution->getAttribute('status', ''));
}
$publisherForExecutions->enqueue(new ExecutionMessage(
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Appwrite\Event\Message;
use Utopia\Database\Document;
final class Database extends Base
{
public function __construct(
public readonly ?Document $project = null,
public readonly ?Document $user = null,
public readonly string $type = '',
public readonly ?Document $table = null,
public readonly ?Document $row = null,
public readonly ?Document $collection = null,
public readonly ?Document $document = null,
public readonly ?Document $database = null,
public readonly array $events = [],
) {
}
public function toArray(): array
{
return [
'project' => $this->project?->getArrayCopy(),
'user' => $this->user?->getArrayCopy(),
'type' => $this->type,
'table' => $this->table?->getArrayCopy(),
'row' => $this->row?->getArrayCopy(),
'collection' => $this->collection?->getArrayCopy(),
'document' => $this->document?->getArrayCopy(),
'database' => $this->database?->getArrayCopy(),
'events' => $this->events,
];
}
public static function fromArray(array $data): static
{
return new self(
project: !empty($data['project']) ? new Document($data['project']) : null,
user: !empty($data['user']) ? new Document($data['user']) : null,
type: $data['type'] ?? '',
table: !empty($data['table']) ? new Document($data['table']) : null,
row: !empty($data['row']) ? new Document($data['row']) : null,
collection: !empty($data['collection']) ? new Document($data['collection']) : null,
document: !empty($data['document']) ? new Document($data['document']) : null,
database: !empty($data['database']) ? new Document($data['database']) : null,
events: $data['events'] ?? [],
);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Appwrite\Event\Message;
use Utopia\Database\Document;
final class Delete extends Base
{
public function __construct(
public readonly ?Document $project = null,
public readonly string $type = '',
public readonly ?Document $document = null,
public readonly ?string $resource = null,
public readonly ?string $resourceType = null,
public readonly ?string $datetime = null,
public readonly ?string $hourlyUsageRetentionDatetime = null,
) {
}
public function toArray(): array
{
return [
'project' => $this->project?->getArrayCopy(),
'type' => $this->type,
'document' => $this->document?->getArrayCopy(),
'resource' => $this->resource,
'resourceType' => $this->resourceType,
'datetime' => $this->datetime,
'hourlyUsageRetentionDatetime' => $this->hourlyUsageRetentionDatetime,
];
}
public static function fromArray(array $data): static
{
return new self(
project: !empty($data['project']) ? new Document($data['project']) : null,
type: $data['type'] ?? '',
document: !empty($data['document']) ? new Document($data['document']) : null,
resource: $data['resource'] ?? null,
resourceType: $data['resourceType'] ?? null,
datetime: $data['datetime'] ?? null,
hourlyUsageRetentionDatetime: $data['hourlyUsageRetentionDatetime'] ?? null,
);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Appwrite\Event\Message;
use Appwrite\Event\Event;
use Utopia\Config\Config;
use Utopia\Database\Document;
final class Func extends Base
{
public function __construct(
public readonly ?Document $project = null,
public readonly ?Document $user = null,
public readonly ?string $userId = null,
public readonly ?Document $function = null,
public readonly ?string $functionId = null,
public readonly ?Document $execution = null,
public readonly string $type = '',
public readonly string $jwt = '',
public readonly array $payload = [],
public readonly array $events = [],
public readonly string $body = '',
public readonly string $path = '',
public readonly array $headers = [],
public readonly string $method = '',
public readonly array $platform = [],
) {
}
public static function fromEvent(
string $event,
array $params,
?Document $project = null,
?Document $user = null,
?string $userId = null,
array $payload = [],
array $platform = [],
): static {
return new self(
project: $project,
user: $user,
userId: $userId,
payload: $payload,
events: $event !== '' ? Event::generateEvents($event, $params) : [],
platform: $platform,
);
}
public function toArray(): array
{
$platform = !empty($this->platform) ? $this->platform : Config::getParam('platform', []);
return [
'project' => $this->project?->getArrayCopy(),
'user' => $this->user?->getArrayCopy(),
'userId' => $this->userId,
'function' => $this->function?->getArrayCopy(),
'functionId' => $this->functionId,
'execution' => $this->execution?->getArrayCopy(),
'type' => $this->type,
'jwt' => $this->jwt,
'payload' => $this->payload,
'events' => $this->events,
'body' => $this->body,
'path' => $this->path,
'headers' => $this->headers,
'method' => $this->method,
'platform' => $platform,
];
}
public static function fromArray(array $data): static
{
return new self(
project: !empty($data['project']) ? new Document($data['project']) : null,
user: !empty($data['user']) ? new Document($data['user']) : null,
userId: $data['userId'] ?? null,
function: !empty($data['function']) ? new Document($data['function']) : null,
functionId: $data['functionId'] ?? null,
execution: !empty($data['execution']) ? new Document($data['execution']) : null,
type: $data['type'] ?? '',
jwt: $data['jwt'] ?? '',
payload: $data['payload'] ?? [],
events: $data['events'] ?? [],
body: $data['body'] ?? '',
path: $data['path'] ?? '',
headers: $data['headers'] ?? [],
method: $data['method'] ?? '',
platform: $data['platform'] ?? [],
);
}
}
@@ -6,8 +6,13 @@ use Utopia\Database\Document;
final class StatsResources extends Base
{
/**
* @param Document $project
* @param array<int, array{metric: string, value: int}> $gauges
*/
public function __construct(
public readonly Document $project,
public readonly array $gauges = [],
) {
}
@@ -15,6 +20,7 @@ final class StatsResources extends Base
{
return [
'project' => $this->project->getArrayCopy(),
'gauges' => $this->gauges,
];
}
@@ -22,6 +28,7 @@ final class StatsResources extends Base
{
return new self(
project: new Document($data['project'] ?? []),
gauges: $data['gauges'] ?? [],
);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Appwrite\Event\Publisher;
use Appwrite\Event\Message\Database as DatabaseMessage;
use Utopia\Database\Document;
use Utopia\DSN\DSN;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
readonly class Database extends Base
{
public function __construct(
Publisher $publisher,
protected Queue $queue,
) {
parent::__construct($publisher);
}
public function enqueue(DatabaseMessage $message, ?Queue $queue = null): string|bool
{
return $this->publish($queue ?? $this->getQueueFromProject($message->project), $message);
}
public function getSize(bool $failed = false, ?Queue $queue = null): int
{
return $this->getQueueSize($queue ?? $this->queue, $failed);
}
private function getQueueFromProject(?Document $project): Queue
{
$database = $project?->getAttribute('database', '');
if (empty($database)) {
return $this->queue;
}
try {
$dsn = new DSN($database);
} catch (\InvalidArgumentException) {
$dsn = new DSN('mysql://' . $database);
}
return new Queue($dsn->getHost());
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Appwrite\Event\Publisher;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
readonly class Delete extends Base
{
public function __construct(
Publisher $publisher,
protected Queue $queue,
) {
parent::__construct($publisher);
}
public function enqueue(DeleteMessage $message, ?Queue $queue = null): string|bool
{
return $this->publish($queue ?? $this->queue, $message);
}
public function getSize(bool $failed = false, ?Queue $queue = null): int
{
return $this->getQueueSize($queue ?? $this->queue, $failed);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Appwrite\Event\Publisher;
use Appwrite\Event\Message\Func as FunctionMessage;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
readonly class Func extends Base
{
public function __construct(
Publisher $publisher,
protected Queue $queue,
) {
parent::__construct($publisher);
}
public function enqueue(FunctionMessage $message, ?Queue $queue = null): string|bool
{
return $this->publish($queue ?? $this->queue, $message);
}
public function getSize(bool $failed = false, ?Queue $queue = null): int
{
return $this->getQueueSize($queue ?? $this->queue, $failed);
}
}
+43 -1
View File
@@ -9,6 +9,18 @@ class StatsResources extends Event
{
protected bool $critical = false;
/**
* Pre-computed gauge metric snapshots to write to the stats collection. When non-empty,
* the StatsResources worker takes the fast path: it writes these directly via
* upsertDocuments (replace semantics) and skips the standard counting work.
*
* Each entry is a tuple of (metric key, value). The worker writes one stats document per
* (metric, period) tuple using the project's region.
*
* @var array<int, array{metric: string, value: int}>
*/
protected array $gauges = [];
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
@@ -18,6 +30,35 @@ class StatsResources extends Event
->setClass(System::getEnv('_APP_STATS_RESOURCES_CLASS_NAME', Event::STATS_RESOURCES_CLASS_NAME));
}
/**
* Set the full set of pre-computed gauge metrics for this message. Replaces any
* previously-set gauges.
*
* @param array<int, array{metric: string, value: int}> $gauges
*/
public function setGauges(array $gauges): self
{
$this->gauges = $gauges;
return $this;
}
/**
* Append a single pre-computed gauge metric to this message.
*/
public function addGauge(string $metric, int $value): self
{
$this->gauges[] = ['metric' => $metric, 'value' => $value];
return $this;
}
/**
* @return array<int, array{metric: string, value: int}>
*/
public function getGauges(): array
{
return $this->gauges;
}
/**
* Prepare the payload for the usage event.
*
@@ -26,7 +67,8 @@ class StatsResources extends Event
protected function preparePayload(): array
{
return [
'project' => $this->project
'project' => $this->project,
'gauges' => $this->gauges,
];
}
}
+12
View File
@@ -202,6 +202,10 @@ class Exception extends \Exception
/** Log */
public const string LOG_NOT_FOUND = 'log_not_found';
/** Presence */
public const string PRESENCE_NOT_FOUND = 'presence_not_found';
public const string PRESENCE_ALREADY_EXISTS = 'presence_already_exists';
/** Databases */
public const string DATABASE_NOT_FOUND = 'database_not_found';
public const string DATABASE_ALREADY_EXISTS = 'database_already_exists';
@@ -406,6 +410,14 @@ class Exception extends \Exception
public const string TOKEN_EXPIRED = 'token_expired';
public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid';
/** Advisor */
public const string INSIGHT_NOT_FOUND = 'insight_not_found';
public const string INSIGHT_ALREADY_EXISTS = 'insight_already_exists';
/** Reports */
public const string REPORT_NOT_FOUND = 'report_not_found';
public const string REPORT_ALREADY_EXISTS = 'report_already_exists';
protected string $type = '';
protected array $errors = [];
protected bool $publish;
+24 -47
View File
@@ -6,7 +6,6 @@ use Appwrite\GraphQL\Exception as GQLException;
use Appwrite\Promises\Swoole;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\DI\Container;
use Utopia\Http\Exception;
use Utopia\Http\Http;
use Utopia\Http\Route;
@@ -53,22 +52,6 @@ class Resolvers
}
}
/**
* Get the current request container.
*/
private static function getResolverContainer(Http $utopia): Container
{
$container = $utopia->getResource('container');
if ($container instanceof Container || (\is_object($container) && \method_exists($container, 'get') && \method_exists($container, 'set'))) {
/** @var Container $container */
return $container;
}
/** @var callable(): Container $container */
return $container();
}
/**
* Get the request-scoped lock shared by GraphQL resolver coroutines
* for the current HTTP request.
@@ -95,9 +78,9 @@ class Resolvers
?Route $route,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$utopia = $utopia->context()->get('utopia:graphql');
$request = $utopia->context()->get('request');
$response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -167,9 +150,9 @@ class Resolvers
callable $url,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$utopia = $utopia->context()->get('utopia:graphql');
$request = $utopia->context()->get('request');
$response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -203,9 +186,9 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$utopia = $utopia->context()->get('utopia:graphql');
$request = $utopia->context()->get('request');
$response = $utopia->context()->get('response');
$beforeResolve = function ($payload) {
return $payload['documents'];
@@ -245,9 +228,9 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$utopia = $utopia->context()->get('utopia:graphql');
$request = $utopia->context()->get('request');
$response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -282,9 +265,9 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$utopia = $utopia->context()->get('utopia:graphql');
$request = $utopia->context()->get('request');
$response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -317,9 +300,9 @@ class Resolvers
callable $url,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
$utopia = $utopia->getResource('utopia:graphql');
$request = $utopia->getResource('request');
$response = $utopia->getResource('response');
$utopia = $utopia->context()->get('utopia:graphql');
$request = $utopia->context()->get('request');
$response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -359,9 +342,9 @@ class Resolvers
$lock->acquire();
$original = $utopia->getRoute();
try {
$request = clone $request;
$request->addHeader('x-appwrite-source', 'graphql');
// Drop json content type so post args are used directly.
if (\str_starts_with($request->getHeader('content-type'), 'application/json')) {
@@ -373,17 +356,15 @@ class Resolvers
}
/** @var Response $resolverResponse */
$resolverResponse = clone $utopia->getResource('response');
$container = self::getResolverContainer($utopia);
$container->set('request', static fn () => $request);
$container->set('response', static fn () => $resolverResponse);
$resolverResponse = clone $utopia->context()->get('response');
$utopia->context()->set('request', static fn () => $request);
$utopia->context()->set('response', static fn () => $resolverResponse);
$resolverResponse->setContentType(Response::CONTENT_TYPE_NULL);
$resolverResponse->setSent(false);
$route = $utopia->match($request, fresh: true);
$request->setRoute($route);
$request->setRoute($utopia->match($request)?->route);
$utopia->execute($route, $request, $resolverResponse);
$utopia->execute($request, $resolverResponse);
self::mergeResponseSideEffects($resolverResponse, $response);
@@ -402,10 +383,6 @@ class Resolvers
$reject($e);
return;
} finally {
if ($original !== null) {
$utopia->setRoute($original);
}
$lock->release();
unset(self::$locks[\spl_object_hash($utopia)]);
}
+1 -1
View File
@@ -84,7 +84,7 @@ class Schema
protected static function api(Http $utopia, callable $complexity): array
{
Mapper::init($utopia
->getResource('response')
->context()->get('response')
->getModels());
$queries = [];
+1 -1
View File
@@ -254,7 +254,7 @@ class Mapper
array $injections
): Type {
$validator = \is_callable($validator)
? \call_user_func_array($validator, $utopia->getResources($injections))
? \call_user_func_array($validator, \array_map($utopia->context()->get(...), $injections))
: $validator;
$isNullable = $validator instanceof Nullable;
@@ -34,6 +34,7 @@ class Realtime extends MessagingAdapter
'account',
'teams',
'memberships',
'presences'
];
/**
@@ -44,6 +45,7 @@ class Realtime extends MessagingAdapter
* 'roles' -> [ROLE_x, ROLE_Y]
* 'userId' -> [USER_ID]
* 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z]
* 'presences' -> [PRESENCE_ID_1, PRESENCE_ID_2, ...]
*/
public array $connections = [];
@@ -146,6 +148,7 @@ class Realtime extends MessagingAdapter
'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))),
'userId' => $userId ?? ($existing['userId'] ?? ''),
'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))),
'presences' => $this->connections[$identifier]['presences'] ?? []
];
if (\array_key_exists('authorization', $existing)) {
@@ -202,6 +205,74 @@ class Realtime extends MessagingAdapter
return $subscriptions;
}
/**
* Dedup delete presence triggers.
* Scenario: when client is connected to realtime and a delete call is made throught rest.
* If not dedupe then two delete events will get triggered. So remove the presenceIds
*
* @param string $projectId
* @param string $presenceId
* @return int Number of connections whose presences map was updated.
*/
public function removePresenceFromConnections(string $projectId, string $presenceId): int
{
if ($projectId === '' || $presenceId === '') {
return 0;
}
$removed = 0;
foreach ($this->connections as $connectionId => $connection) {
if (($connection['projectId'] ?? null) !== $projectId) {
continue;
}
if (!isset($connection['presences'][$presenceId])) {
continue;
}
unset($this->connections[$connectionId]['presences'][$presenceId]);
$removed++;
}
return $removed;
}
/**
* Returns the presence ID carried by a `presences.{id}.delete` event payload,
* or null when the event is not a presence delete.
*
* @param array $event Decoded pubsub payload produced by self::send().
* @return string|null
*/
public static function extractDeletedPresenceId(array $event): ?string
{
$events = $event['data']['events'] ?? [];
if (!\is_array($events)) {
return null;
}
$isPresenceDelete = false;
foreach ($events as $eventName) {
if (
\is_string($eventName)
&& \str_starts_with($eventName, 'presences.')
&& \str_ends_with($eventName, '.delete')
) {
$isPresenceDelete = true;
break;
}
}
if (!$isPresenceDelete) {
return null;
}
$presenceId = $event['data']['payload']['$id'] ?? null;
if (!\is_string($presenceId) || $presenceId === '') {
return null;
}
return $presenceId;
}
/**
* Removes all subscriptions for a connection.
*
@@ -774,6 +845,26 @@ class Realtime extends MessagingAdapter
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
}
break;
case 'reports':
// Plain report event: `reports.{reportId}.{action}`
$channels[] = 'reports';
if (isset($parts[1])) {
$channels[] = 'reports.' . $parts[1];
}
// Nested insight event: `reports.{reportId}.insights.{insightId}.{action}`
if (isset($parts[2]) && $parts[2] === 'insights') {
$channels[] = 'reports.' . $parts[1] . '.insights';
if (isset($parts[3])) {
$channels[] = 'reports.' . $parts[1] . '.insights.' . $parts[3];
}
}
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
break;
case 'presences':
$channels[] = 'presences';
$channels[] = 'presences.' . $parts[1];
$roles = $payload->getRead();
break;
}
// Action is the last segment for plain CRUD events (e.g. `documents.X.create`),
+27
View File
@@ -55,6 +55,9 @@ class V24 extends Migration
if ($this->project->getSequence() != 'console') {
Console::info('Migrating Databases');
$this->migrateDatabases();
Console::info('Creating presence logs collection');
$this->createPresenceLogsCollection();
}
Console::info('Migrating Buckets');
@@ -330,6 +333,30 @@ class V24 extends Migration
});
}
/**
* Ensure the presenceLogs collection exists for project databases.
*
* @return void
* @throws Throwable
*/
private function createPresenceLogsCollection(): void
{
$collectionId = 'presenceLogs';
try {
Console::info("Ensuring collection \"{$collectionId}\" exists for project \"{$this->project->getId()}\".");
$this->dbForProject->purgeCachedCollection($collectionId);
$this->dbForProject->purgeCachedDocument(Database::METADATA, $collectionId);
$this->createCollection($collectionId);
} catch (Throwable $th) {
Console::warning("Failed to create collection \"{$collectionId}\": {$th->getMessage()}");
// Re-throw so the migration fails fast and doesn't leave the system in a partially migrated state.
throw $th;
}
}
/**
* Migrate all Bucket tables
*
+6
View File
@@ -3,6 +3,7 @@
namespace Appwrite\Platform;
use Appwrite\Platform\Modules\Account;
use Appwrite\Platform\Modules\Advisor;
use Appwrite\Platform\Modules\Avatars;
use Appwrite\Platform\Modules\Console;
use Appwrite\Platform\Modules\Core;
@@ -10,6 +11,8 @@ use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Health;
use Appwrite\Platform\Modules\Migrations;
use Appwrite\Platform\Modules\Organization;
use Appwrite\Platform\Modules\Presences;
use Appwrite\Platform\Modules\Project;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
@@ -30,6 +33,7 @@ class Appwrite extends Platform
$this->addModule(new Avatars\Module());
$this->addModule(new Databases\Module());
$this->addModule(new Projects\Module());
$this->addModule(new Presences\Module());
$this->addModule(new Functions\Module());
$this->addModule(new Health\Module());
$this->addModule(new Sites\Module());
@@ -41,6 +45,8 @@ class Appwrite extends Platform
$this->addModule(new VCS\Module());
$this->addModule(new Webhooks\Module());
$this->addModule(new Migrations\Module());
$this->addModule(new Organization\Module());
$this->addModule(new Project\Module());
$this->addModule(new Advisor\Module());
}
}
+1 -1
View File
@@ -154,7 +154,7 @@ class Server
$nativeServer = $adapter->getNativeServer();
$container = $adapter->getContainer();
$container = $adapter->resources();
$container->set('installerState', fn () => $state);
$container->set('installerConfig', fn () => $config);
$container->set('installerPaths', fn () => $paths);
@@ -0,0 +1,8 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Enums;
enum InsightCTAMethod: string
{
case CREATE_INDEX = 'createIndex';
}
@@ -0,0 +1,11 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Enums;
enum InsightCTAService: string
{
case DATABASES = 'databases';
case TABLES_DB = 'tablesDB';
case DOCUMENTS_DB = 'documentsDB';
case VECTORS_DB = 'vectorsDB';
}
@@ -0,0 +1,10 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Enums;
enum InsightSeverity: string
{
case INFO = 'info';
case WARNING = 'warning';
case CRITICAL = 'critical';
}
@@ -0,0 +1,9 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Enums;
enum InsightStatus: string
{
case ACTIVE = 'active';
case DISMISSED = 'dismissed';
}
@@ -0,0 +1,16 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Enums;
enum InsightType: string
{
case DATABASE_INDEX = 'databaseIndex';
case TABLES_DB_INDEX = 'tablesDBIndex';
case DOCUMENTS_DB_INDEX = 'documentsDBIndex';
case VECTORS_DB_INDEX = 'vectorsDBIndex';
case DATABASE_PERFORMANCE = 'databasePerformance';
case SITE_PERFORMANCE = 'sitePerformance';
case SITE_ACCESSIBILITY = 'siteAccessibility';
case SITE_SEO = 'siteSeo';
case FUNCTION_PERFORMANCE = 'functionPerformance';
}
@@ -0,0 +1,10 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Enums;
enum ReportType: string
{
case LIGHTHOUSE = 'lighthouse';
case AUDIT = 'audit';
case DATABASE_ANALYZER = 'databaseAnalyzer';
}
@@ -0,0 +1,84 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Http\Insights;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getInsight';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/reports/:reportId/insights/:insightId')
->desc('Get insight')
->groups(['api', 'advisor'])
->label('scope', 'insights.read')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('sdk', new Method(
namespace: 'advisor',
group: 'insights',
name: 'getInsight',
description: '/docs/references/advisor/get-insight.md',
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_INSIGHT,
),
]
))
->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform'])
->inject('response')
->inject('project')
->inject('dbForPlatform')
->callback($this->action(...));
}
public function action(
string $reportId,
string $insightId,
Response $response,
Document $project,
Database $dbForPlatform
) {
// Skip the insights subquery — we only need ownership metadata.
$report = $dbForPlatform->skipFilters(
fn () => $dbForPlatform->getDocument('reports', $reportId),
['subQueryReportInsights'],
);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
$insight = $dbForPlatform->getDocument('insights', $insightId);
if (
$insight->isEmpty()
|| $insight->getAttribute('projectInternalId') !== $project->getSequence()
|| $insight->getAttribute('reportInternalId') !== $report->getSequence()
) {
throw new Exception(Exception::INSIGHT_NOT_FOUND);
}
$response->dynamic($insight, Response::MODEL_INSIGHT);
}
}
@@ -0,0 +1,126 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Http\Insights;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Insights;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listInsights';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/reports/:reportId/insights')
->desc('List insights')
->groups(['api', 'advisor'])
->label('scope', 'insights.read')
->label('resourceType', RESOURCE_TYPE_INSIGHTS)
->label('sdk', new Method(
namespace: 'advisor',
group: 'insights',
name: 'listInsights',
description: '/docs/references/advisor/list-insights.md',
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_INSIGHT_LIST,
),
]
))
->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
->param('queries', [], new Insights(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Insights::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
->callback($this->action(...));
}
public function action(
string $reportId,
array $queries,
bool $includeTotal,
Response $response,
Document $project,
Database $dbForPlatform
) {
// Skip the insights subquery — we're about to fetch a filtered, paginated slice ourselves.
$report = $dbForPlatform->skipFilters(
fn () => $dbForPlatform->getDocument('reports', $reportId),
['subQueryReportInsights'],
);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$queries[] = Query::equal('projectInternalId', [$project->getSequence()]);
$queries[] = Query::equal('reportInternalId', [$report->getSequence()]);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$insightId = $cursor->getValue();
$cursorDocument = $dbForPlatform->getDocument('insights', $insightId);
if (
$cursorDocument->isEmpty()
|| $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()
|| $cursorDocument->getAttribute('reportInternalId') !== $report->getSequence()
) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Insight '{$insightId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$insights = $dbForPlatform->find('insights', $queries);
$total = $includeTotal ? $dbForPlatform->count('insights', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'insights' => $insights,
'total' => $total,
]), Response::MODEL_INSIGHT_LIST);
}
}
@@ -0,0 +1,100 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Http\Reports;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName(): string
{
return 'deleteReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/reports/:reportId')
->desc('Delete report')
->groups(['api', 'advisor'])
->label('scope', 'reports.write')
->label('event', 'reports.[reportId].delete')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('audits.event', 'report.delete')
->label('audits.resource', 'report/{request.reportId}')
->label('abuse-key', 'projectId:{projectId},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', new Method(
namespace: 'advisor',
group: 'reports',
name: 'deleteReport',
description: '/docs/references/advisor/delete-report.md',
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
),
],
contentType: ContentType::NONE
))
->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('publisherForDeletes')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $reportId,
Response $response,
Document $project,
Database $dbForPlatform,
DeletePublisher $publisherForDeletes,
Event $queueForEvents
): void {
$report = $dbForPlatform->skipFilters(
fn () => $dbForPlatform->getDocument('reports', $reportId),
['subQueryReportInsights'],
);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
if (!$dbForPlatform->deleteDocument('reports', $report->getId())) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove report from DB');
}
$publisherForDeletes->enqueue(new DeleteMessage(
project: $project,
type: DELETE_TYPE_REPORT,
document: $report,
));
$queueForEvents
->setParam('reportId', $report->getId())
->setPayload($response->output($report, Response::MODEL_REPORT));
$response->noContent();
}
}
@@ -0,0 +1,80 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Http\Reports;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/reports/:reportId')
->desc('Get report')
->groups(['api', 'advisor'])
->label('scope', 'reports.read')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('sdk', new Method(
namespace: 'advisor',
group: 'reports',
name: 'getReport',
description: '/docs/references/advisor/get-report.md',
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_REPORT,
),
]
))
->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
->inject('response')
->inject('project')
->inject('dbForPlatform')
->callback($this->action(...));
}
public function action(
string $reportId,
Response $response,
Document $project,
Database $dbForPlatform
) {
$report = $dbForPlatform->skipFilters(
fn () => $dbForPlatform->getDocument('reports', $reportId),
['subQueryReportInsights'],
);
if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::REPORT_NOT_FOUND);
}
$insights = $dbForPlatform->find('insights', [
Query::equal('projectInternalId', [$project->getSequence()]),
Query::equal('reportInternalId', [$report->getSequence()]),
Query::limit(APP_LIMIT_SUBQUERY),
]);
$report->setAttribute('insights', $insights);
$response->dynamic($report, Response::MODEL_REPORT);
}
}
@@ -0,0 +1,133 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Http\Reports;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Reports;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listReports';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/reports')
->desc('List reports')
->groups(['api', 'advisor'])
->label('scope', 'reports.read')
->label('resourceType', RESOURCE_TYPE_REPORTS)
->label('sdk', new Method(
namespace: 'advisor',
group: 'reports',
name: 'listReports',
description: '/docs/references/advisor/list-reports.md',
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_REPORT_LIST,
),
]
))
->param('queries', [], new Reports(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Reports::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
->callback($this->action(...));
}
public function action(
array $queries,
bool $includeTotal,
Response $response,
Document $project,
Database $dbForPlatform
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$queries[] = Query::equal('projectInternalId', [$project->getSequence()]);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$reportId = $cursor->getValue();
$cursorDocument = $dbForPlatform->skipFilters(
fn () => $dbForPlatform->getDocument('reports', $reportId),
['subQueryReportInsights'],
);
if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Report '{$reportId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$reports = $dbForPlatform->skipFilters(
fn () => $dbForPlatform->find('reports', $queries),
['subQueryReportInsights'],
);
$total = $includeTotal ? $dbForPlatform->count('reports', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
if (!empty($reports)) {
$reportSequences = \array_map(fn (Document $r) => $r->getSequence(), $reports);
$insights = $dbForPlatform->find('insights', [
Query::equal('projectInternalId', [$project->getSequence()]),
Query::equal('reportInternalId', $reportSequences),
Query::limit(APP_LIMIT_SUBQUERY),
]);
$insightsByReport = [];
foreach ($insights as $insight) {
$insightsByReport[$insight->getAttribute('reportInternalId')][] = $insight;
}
foreach ($reports as $report) {
$report->setAttribute('insights', $insightsByReport[$report->getSequence()] ?? []);
}
}
$response->dynamic(new Document([
'reports' => $reports,
'total' => $total,
]), Response::MODEL_REPORT_LIST);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Advisor;
use Appwrite\Platform\Modules\Advisor\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,25 @@
<?php
namespace Appwrite\Platform\Modules\Advisor\Services;
use Appwrite\Platform\Modules\Advisor\Http\Insights\Get as GetInsight;
use Appwrite\Platform\Modules\Advisor\Http\Insights\XList as ListInsights;
use Appwrite\Platform\Modules\Advisor\Http\Reports\Delete as DeleteReport;
use Appwrite\Platform\Modules\Advisor\Http\Reports\Get as GetReport;
use Appwrite\Platform\Modules\Advisor\Http\Reports\XList as ListReports;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
$this->addAction(GetReport::getName(), new GetReport());
$this->addAction(ListReports::getName(), new ListReports());
$this->addAction(DeleteReport::getName(), new DeleteReport());
$this->addAction(GetInsight::getName(), new GetInsight());
$this->addAction(ListInsights::getName(), new ListInsights());
}
}
@@ -72,7 +72,7 @@ class Get extends Action
->param('userAgent', '', new Text(512), 'Custom user agent string. Defaults to browser default.', true, example: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15')
->param('fullpage', false, new Boolean(true), 'Capture full page scroll. Pass 0 for viewport only, or 1 for full page. Defaults to 0.', true, example: 'true')
->param('locale', '', new Text(10), 'Browser locale (e.g., "en-US", "fr-FR"). Defaults to browser default.', true, example: 'en-US')
->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'america/new_york')
->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'America/New_York')
->param('latitude', 0, new Range(-90, 90, Range::TYPE_FLOAT), 'Geolocation latitude. Pass a number between -90 to 90. Defaults to 0.', true, example: '37.7749')
->param('longitude', 0, new Range(-180, 180, Range::TYPE_FLOAT), 'Geolocation longitude. Pass a number between -180 to 180. Defaults to 0.', true, example: '-122.4194')
->param('accuracy', 0, new Range(0, 100000, Range::TYPE_FLOAT), 'Geolocation accuracy in meters. Pass a number between 0 to 100000. Defaults to 0.', true, example: '100')
@@ -0,0 +1,69 @@
<?php
namespace Appwrite\Platform\Modules\Console\Http\Scopes\Organization;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listConsoleOrganizationScopes';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/console/scopes/organization')
->desc('List organization scopes')
->groups(['api'])
->label('scope', 'public')
->label('sdk', new Method(
namespace: 'console',
group: 'console',
name: 'listOrganizationScopes',
description: 'List all scopes available for organization API keys, along with a description for each scope.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST,
)
],
contentType: ContentType::JSON
))
->inject('response')
->callback($this->action(...));
}
public function action(Response $response): void
{
$scopesConfig = Config::getParam('organizationScopes', []);
$scopes = [];
foreach ($scopesConfig as $scopeId => $scope) {
$scopes[] = new Document([
'$id' => $scopeId,
'description' => $scope['description'] ?? '',
'category' => $scope['category'] ?? '',
'deprecated' => $scope['deprecated'] ?? false,
]);
}
$response->dynamic(new Document([
'total' => \count($scopes),
'scopes' => $scopes,
]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Console\Http\Scopes\Key;
namespace Appwrite\Platform\Modules\Console\Http\Scopes\Project;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -0,0 +1,123 @@
<?php
namespace Appwrite\Platform\Modules\Console\Http\Templates\Email;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Document;
use Utopia\Locale\Locale;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\WhiteList;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getConsoleEmailTemplate';
}
public function __construct()
{
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/console/templates/email/:templateId')
->desc('Get email template')
->groups(['api'])
->label('scope', 'public')
->label('sdk', new Method(
namespace: 'console',
group: null,
name: 'getEmailTemplate',
description: <<<EOT
Get the Appwrite built-in default email template for the specified type and locale. Always returns the unmodified default, ignoring any custom project overrides.
EOT,
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_EMAIL_TEMPLATE,
)
]
))
->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Email template type. Can be one of: ' . \implode(', ', Config::getParam('locale-templates')['email'] ?? []))
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
->inject('response')
->callback($this->action(...));
}
public function action(
string $templateId,
string $locale,
Response $response,
): void {
$locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$localeObj = new Locale($locale);
$localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
$response->dynamic(new Document([
'templateId' => $templateId,
'locale' => $locale,
'subject' => $localeObj->getText('emails.' . $templateId . '.subject'),
'message' => $this->getDefaultMessage($templateId, $localeObj),
'senderName' => '',
'senderEmail' => '',
'replyToEmail' => '',
'replyToName' => '',
]), Response::MODEL_EMAIL_TEMPLATE);
}
private function getDefaultMessage(string $templateId, Locale $localeObj): string
{
$templateConfigs = [
'magicSession' => [
'file' => 'email-magic-url.tpl',
'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase']
],
'mfaChallenge' => [
'file' => 'email-mfa-challenge.tpl',
'placeholders' => ['description', 'clientInfo']
],
'otpSession' => [
'file' => 'email-otp.tpl',
'placeholders' => ['description', 'clientInfo', 'securityPhrase']
],
'sessionAlert' => [
'file' => 'email-session-alert.tpl',
'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer']
],
];
$config = $templateConfigs[$templateId] ?? [
'file' => 'email-inner-base.tpl',
'placeholders' => ['buttonText', 'body', 'footer']
];
$templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']);
$message = Template::fromString($templateString);
foreach ($config['placeholders'] as $param) {
$escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']);
if ($templateId === 'magicSession' && $param === 'securityPhrase') {
$message->setParam('{{securityPhrase}}', '');
continue;
}
$message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml);
}
$message
->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello"))
->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks"))
->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature"));
return $message->render(useContent: true);
}
}
@@ -15,7 +15,9 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco
use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister;
use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot;
use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability;
use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes;
use Appwrite\Platform\Modules\Console\Http\Scopes\Organization\XList as ListOrganizationScopes;
use Appwrite\Platform\Modules\Console\Http\Scopes\Project\XList as ListKeyScopes;
use Appwrite\Platform\Modules\Console\Http\Templates\Email\Get as GetEmailTemplate;
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
use Utopia\Platform\Service;
@@ -30,8 +32,10 @@ class Http extends Service
$this->addAction(Web::getName(), new Web());
$this->addAction(GetVariables::getName(), new GetVariables());
$this->addAction(GetEmailTemplate::getName(), new GetEmailTemplate());
$this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
$this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
$this->addAction(ListOrganizationScopes::getName(), new ListOrganizationScopes());
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Database as DatabaseMessage;
use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response as UtopiaResponse;
@@ -312,7 +313,7 @@ abstract class Action extends UtopiaAction
};
}
protected function createAttribute(string $databaseId, string $collectionId, Document $attribute, Response $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): Document
protected function createAttribute(string $databaseId, string $collectionId, Document $attribute, Response $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): Document
{
$key = $attribute->getAttribute('key');
$type = $attribute->getAttribute('type', '');
@@ -464,20 +465,6 @@ abstract class Action extends UtopiaAction
$dbForProject->purgeCachedCollection('database_' . $db->getSequence() . '_collection_' . $relatedCollection->getSequence());
}
$queueForDatabase
->setType(DATABASE_TYPE_CREATE_ATTRIBUTE)
->setDatabase($db);
if ($this->isCollectionsAPI()) {
$queueForDatabase
->setDocument($attribute)
->setCollection($collection);
} else {
$queueForDatabase
->setRow($attribute)
->setTable($collection);
}
$queueForEvents
->setContext('database', $db)
->setParam('databaseId', $databaseId)
@@ -487,6 +474,18 @@ abstract class Action extends UtopiaAction
->setParam('columnId', $attribute->getId())
->setContext($this->getCollectionsEventsContext(), $collection);
$publisherForDatabase->enqueue(new DatabaseMessage(
project: $queueForEvents->getProject(),
user: $queueForEvents->getUser(),
type: DATABASE_TYPE_CREATE_ATTRIBUTE,
database: $db,
collection: $this->isCollectionsAPI() ? $collection : null,
document: $this->isCollectionsAPI() ? $attribute : null,
table: $this->isCollectionsAPI() ? null : $collection,
row: $this->isCollectionsAPI() ? null : $attribute,
events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()),
));
$response->setStatusCode(SwooleResponse::STATUS_CODE_CREATED);
return $attribute;
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -73,13 +73,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
->inject('queueForDatabase')
->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$min ??= \PHP_INT_MIN;
$max ??= \PHP_INT_MAX;
@@ -102,7 +102,7 @@ class Create extends Action
'array' => $array,
'format' => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE,
'formatOptions' => ['min' => $min, 'max' => $max],
]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$formatOptions = $attribute->getAttribute('formatOptions', []);
if (!empty($formatOptions)) {
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Boolean;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Deprecated;
@@ -68,13 +68,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
->inject('queueForDatabase')
->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$attribute = $this->createAttribute($databaseId, $collectionId, new Document([
'key' => $key,
@@ -83,7 +83,7 @@ class Create extends Action
'required' => $required,
'default' => $default,
'array' => $array,
]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$response
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)

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