Commit 663959d
authored
feat: s3 and gcs integrations (#464)
* feat: S3/GCS data export integration
Export a project's analytics events to the user's own S3 or GCS bucket.
ClickHouse is the single source of truth for the export — there is no Redis
buffer and no per-event ingestion hook, so a stalled/failing bucket can never
touch the ingestion path (in either the Kafka or GroupMQ mode).
How it works:
- A new `inserted_at DateTime64(3)` column on the events table records ingestion
time. It DEFAULTs to `created_at` (deterministic for pre-migration parts — a
DEFAULT now() would be evaluated lazily at read time and never settle) and is
set explicitly at insert time (createEvent + the import production-move), so
backdated events (server-side, offline, imports) still get a real insert time.
A minmax skip index keeps the windowed scan cheap since inserted_at isn't in
the primary key.
- The flushExports cron job (every 60s) windows the events table by inserted_at
for each (project, integration), batches rows into gzipped JSONL + a manifest,
and uploads them. A per-(project, integration) ExportWatermark (Postgres) tracks
progress with a composite (inserted_at, id) cursor — the id tie-breaker is
required because an import stamps one inserted_at across its whole batch. A
safety lag behind now() avoids reading in-flight inserts; the watermark only
advances after a successful upload (at-least-once; the manifest is the commit
marker). First run starts from now(), so connecting an export doesn't dump all
history (historical backfill = reset the watermark).
Integrations are organization-scoped, so every active project in the org exports
independently and gets its own watermark + object path (layout keyed by
project_id). Destinations are a pluggable object-store sink (S3 + GCS adapters)
so future targets are small additions.
Credentials (S3 secret keys, GCS service-account keys) are encrypted at rest with
CREDENTIALS_ENCRYPTION_KEY and decrypted inside the adapters. Export batching and
the safety lag are tunable via EXPORT_* env vars.
Rebased onto main after the Redpanda/Kafka migration.
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* feat: scope integrations to projects (dual-scope)
Phase 1 of the integrations rework. Adds a nullable Integration.projectId:
set = project-scoped, null = legacy org-wide. Existing org integrations keep
working untouched (additive migration, no backfill).
- schema: Integration.projectId (nullable FK) + indexes; Project reverse relation.
- export cron: project-scoped integrations export only their project; legacy
org-wide rows still fan out across the org's projects.
- validation/tRPC: create inputs take projectId (org derived from project);
added in-handler getProjectAccess to list + createOrUpdate* (closing a
pre-existing authz gap that trusted client-supplied scope); get/delete use
project-or-org access by scope. list also surfaces legacy org-wide rows.
- notification rules: connected integrations must belong to the same project
(or be org-wide in the same org); also added the missing access check on the
rule create branch.
- Slack OAuth carries projectId through install metadata + callback redirect.
- dashboard: integrations moved to project-scoped routes + sidebar; org route
and org-level "add integration" CTA removed.
typecheck + tests green (657).
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* refactor: integration plugin registry (generic dispatch)
Phase 2 of the integrations rework. Restructures integrations as three
registries keyed by the same `type` literal so adding one is additive — no
edits to central switches — while keeping types bulletproof.
Type safety (the priority): IIntegrationConfig stays the explicit, hand-written
discriminated union (source of truth, feeds Prisma's IPrismaIntegrationConfig).
It is NOT derived from the registry. The registries are forced to MATCH it via
`satisfies Record<IIntegrationConfig['type'], …>`, and two compile-time guards
in validation fail the build if any union member loses its literal `type`
discriminant or if the descriptor set drifts from the union.
- core (packages/validation/src/integrations.ts): per-type zod schemas + an
INTEGRATION_DESCRIPTORS registry (kinds/setup/configSchema/catalog),
getDescriptor/isKind, a runtime zIntegrationConfig, a generic
zCreateIntegration, and the type-level guards.
- server (packages/integrations/src/registry.ts): IServerIntegration<T> +
SERVER_INTEGRATIONS with notification.deliver / export.createAdapter /
validateConfig / testConnection / encryptCredentials hooks. The bespoke
slack/discord/webhook send bodies and s3/gcs adapter+encrypt logic moved here.
getServerIntegration<T> holds the one contained cast.
- client (apps/start integrations.tsx): CLIENT_INTEGRATIONS (icon + Form) keyed
by type; the catalog is derived from descriptors. add-integration.tsx renders
via registry lookup instead of a switch.
- dispatch made generic: worker notification.ts and cron.flush-exports.ts look
up the plugin; the tRPC router collapses to a generic createOrUpdate +
testConnection delegating to plugin hooks (export/slack procedures kept as
thin aliases for one release).
- fixed the discord form's server value-import (browser-bundle leak) by routing
its test through trpc.integration.testConnection.
Deferred (demand-driven): the generic /oauth/:type route only benefits a second
OAuth integration and carries external Slack-redirect-URI risk, so slack OAuth
is unchanged. Legacy zCreate* schemas + tRPC aliases kept until the dashboard
drops them.
typecheck + tests green (657).
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* fix: make isKind lenient for empty/unknown integration config
The flushExports cron lists every integration and filters by
isKind(config, 'export'). A Slack integration that hasn't completed OAuth has
an empty config ({} with no type), and the registry refactor made isKind throw
"Unknown integration type: undefined" on it, failing the whole cron run.
isKind now returns false for unknown/undefined types instead of throwing (it's
a filter predicate, not a guaranteed-known lookup). Also guard the notification
worker against delivering to an unconfigured integration. Adds an isKind
regression test.
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* refactor: one encryption key for all at-rest secrets
There were two AES-256-GCM modules with two env keys for the same purpose:
db/src/encryption.ts (ENCRYPTION_KEY, for TOTP/GSC) and
common/server/encryption.ts (CREDENTIALS_ENCRYPTION_KEY, for integration creds,
needed in @openpanel/integrations which can't import db).
Consolidate to one implementation in @openpanel/common/server keyed solely by
ENCRYPTION_KEY. It hosts both the plain encrypt/decrypt (unchanged format, so
existing TOTP/GSC ciphertext still decrypts) and the prefixed
encryptCredential/decryptCredential (idempotent, plaintext-passthrough for the
test-connection flow). db/src/encryption.ts now re-exports encrypt/decrypt, so
@openpanel/db importers are unchanged.
Drops CREDENTIALS_ENCRYPTION_KEY from .env.example. Adds an encryption
round-trip test. Note: any integration credentials encrypted on this branch
with the old key must be re-saved (no prod data — feature is unmerged).
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* chore: remove dead integration-registry exports
PR-review cleanup: getDescriptor, descriptorsByKind, zCreateIntegration and
ICreateIntegration in validation/src/integrations.ts had zero callers (the
client derives the catalog from INTEGRATION_DESCRIPTORS directly, isKind uses
the type map directly, and the tRPC create procedure inlines its own
config-required input). The type-safety guards and isKind are unaffected.
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* fix: slack OAuth callback no longer redirects to a deleted route
Integrations became project-scoped, so the org-level
/$organizationId/integrations/installed route was removed. The slack callback's
projectId-absent fallback still pointed there, 404-ing older in-flight installs
(metadata without projectId). Fall back to the org landing page instead.
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* fix: SSRF guards + cross-project integration update authz
Security-review findings (all verified against current code):
- Cross-project update (integration.ts): the generic upsert and the Slack
create/update authorized against the client-supplied input.projectId, so a
user with access to one project could update another project's integration in
the same org. Updates now load the existing row and authorize against ITS
scope via assertIntegrationAccess (project access, or org access for legacy
org-wide rows).
- Webhook SSRF (registry.ts): user-configured webhook URLs were fetched
directly. Now guarded by assertSafeUrl before dispatch.
- S3 custom-endpoint SSRF (s3-adapter.ts): a tenant-controlled endpoint could
point at internal services. The resolved host is now SSRF-checked in
getClient() before connecting (covers upload + testConnection).
assertSafeUrl is a new shared helper in @openpanel/common/server: rejects
non-http(s) schemes and hosts resolving to loopback/private/CGNAT/link-local
(incl. 169.254.169.254 metadata). Skipped on SELF_HOSTED, where the single
operator already controls the network and internal targets are legitimate
(blocking them would regress existing behavior). Adds unit tests.
Skipped: tightening the S3 endpoint zod to https-only — it would break
legitimate self-hosted http MinIO/internal stores, and the runtime guard
(scheme + resolved-IP, cloud-gated) is the actual SSRF protection.
Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
* chore(db): renumber migrations after rebase onto main
main landed migrations up to 20260825120100 and code-migration 20 while
this branch was open, so the two Prisma migrations and the ClickHouse
code-migration added here sorted *before* them and code-migration 18 was
a duplicate number.
Renumbered to sort last; contents unchanged. None of these have been
deployed, so renaming the directories is safe.
* fix(integrations): gate mutations on project write access
The router authorized every procedure by testing `getProjectAccess` /
`getOrganizationAccess` for truthiness, which only proves membership. A
read-only project member could create, update and delete integrations —
the pattern main's access ladder (GHSA-f9rx-pxgw-c6rg) replaced.
- create / update / delete now require `level: 'write'`; `get` and `list`
stay at `'read'`.
- Legacy org-wide rows (projectId null) have no project access level to
consult and are shared by every project in the org, so writing to one is
admin-tier; reading still only needs membership.
- `testConnection` / `testExportConnection` had no check at all. They make
the server connect outbound to a caller-supplied destination with
caller-supplied credentials, so they now take a projectId and require
write access too.
* test(integrations): cover the GCS export path against a real GCS API
Only the S3-compatible path had been exercised. The Google SDK has no
in-process double and real GCS needs live credentials, so these run
against a local fake-gcs-server and skip when it isn't reachable.
Two bugs the tests turned up:
- `testConnection` used `bucket.exists()`, which needs
`storage.buckets.get`. The least-privilege grant for an export target
(roles/storage.objectCreator / objectAdmin) does not include it, so a
correctly configured bucket failed setup with a 403 while the export
itself would have worked. It now writes and removes a probe object,
exercising the permission the export actually needs, and maps 404/403
to a message that says what to do.
- `upload` re-read `file.getMetadata()` purely for the etag, doubling the
request count of every export. `save` already populates `file.metadata`.
Also adds a `GCS_API_ENDPOINT` override so the client can be pointed at an
emulator. The SDK's own STORAGE_EMULATOR_HOST is unusable on v7: it
rewrites the JSON API base but not the upload base, so uploads and
metadata reads cannot both resolve.
Drops a duplicate @openpanel/common key in the package manifest (a rebase
artifact) and moves @openpanel/logger to dependencies, where the adapters
import it at runtime.
* chore(db): renumber migrations after rebase onto main (again)
main landed migration 20260828120000 and code-migrations 20/21 since the
last renumber, so this branch's two Prisma migrations and the ClickHouse
code-migration sorted before them again.
Renumbered to sort last; contents unchanged. None of these have been
deployed, so renaming the directories is safe.
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
* fix(integrations): address CodeRabbit review findings
- encryption: concatenate the decipher buffers before decoding. AES-GCM is a
stream cipher, so update() can end mid multi-byte UTF-8 character and the
implicit per-chunk toString would emit replacement chars. Covered by a test.
- ssrf: compare SELF_HOSTED to "true"/"1" instead of bare truthiness, matching
the rest of the repo. SELF_HOSTED="false" was skipping assertPublicUrl before
connecting to a tenant-supplied S3 endpoint.
- ssrf test: restore an originally-unset SELF_HOSTED by deleting it (assigning
undefined leaves the truthy string "undefined").
- exports: drop `parquet` from ExportFormat and the config schemas. createBatch
always threw for it, so a parquet-configured integration could never export.
- code-migration 22: MATERIALIZE INDEX after ADD INDEX so existing event parts
are indexed, same as 18-events-profile-id-index.ts.
- slack: build the install URL from the authorized integration's own projectId
on update, so the OAuth metadata and post-callback redirect can't point at a
different project than the row.
- notification rules: reject integrations without the `notification` kind and
filter them out of the picker. S3/GCS rows were selectable and only failed
later in the worker with "is not a notification sink".
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
* fix(integrations): pin GCS credential type, make export secrets write-only
Two findings from a security review of this PR.
HIGH — GCS credential type confusion (arbitrary file read + SSRF).
`GCSAdapter.getStorage` JSON.parsed the tenant-supplied `serviceAccountKey`
and handed the raw document to `new Storage({ credentials })`. google-auth-
library dispatches on the document's `type`: an `external_account` document is
routed to ExternalAccountClient, whose `credential_source.file` reads any local
path and whose `credential_source.url` issues an unguarded request with
attacker-chosen headers, with the result POSTed to an unvalidated `token_url`.
Verified against the vendored google-auth-library@9.15.1: googleauth.js:440-462
dispatch, identitypoolclient.js:74-88 suppliers, baseexternalclient.js:110
token_url. Anyone with write on any one project could read /proc/self/environ
off the API process — which holds ENCRYPTION_KEY, the single key for all
at-rest secrets — via the Test connection button, and again from the worker on
every flushExports tick once saved.
Now parsed and pinned by `parseServiceAccountKey` before it reaches the SDK,
and the client is built from allowlisted fields with no `type` key at all, so
GoogleAuth can only fall through to its JWT branch. The zod schema rejects
non-service-account documents on the way in; the adapter re-checks, covering
rows written before the schema and any future caller.
MEDIUM — replayable credential ciphertext exposed at read access.
`integration.list`/`get` returned the whole config, including the `enc:`
ciphertext, to anyone with project membership, and `decryptCredential` accepts
any `enc:` blob under the global key with no binding to the row. A read-only
member could lift a ciphertext and replay it through `testConnection` or a new
integration to make the server authenticate as those credentials against a
destination of their choosing.
Credentials are now write-only: redacted on read, rejected if an `enc:` value
arrives on any input path, and carried over from the stored row when an update
submits a blank (the forms say so). One `secretFields` declaration per plugin
drives encrypt/redact/carry-over, replacing the `encryptCredentials` hook, so
the three can't drift apart as integrations are added.
Tests: credential-type rejection at both the schema and adapter layers, and the
secret lifecycle. Verified against a live fake-gcs-server — uploads, manifests
and the encrypted-key path all still work (994 passing with the emulator up).
Fixed two test fixtures that carried incomplete service-account documents.
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
* fix(integrations): pin the S3 endpoint socket, redact Slack/webhook secrets
Follow-ups to the security review.
DNS rebinding on the S3 custom endpoint. `assertSafeUrl` validated the resolved
address and threw it away; the AWS SDK then resolved the hostname again on its
own, so a tenant endpoint whose DNS answer flipped between the check and the
connect would reach an internal host. `assertSafeUrl` now returns the addresses
it validated (null when skipped on self-hosted) and the S3 client is built with
http/https agents whose `lookup` only ever yields that address — the same
pinning `safeFetch` already does for fetch callers, via a shared
`createPinnedLookup`. Only the access-key path takes an endpoint; assumed-role
clients always talk to the default AWS endpoint. The hostname still travels as
SNI and Host, so TLS verification is unchanged.
`S3Adapter.testConnection` also echoed raw SDK errors to the caller, which made
it a probe for whatever the worker could reach. Common cases now map to
actionable messages and everything else (DNS, connection, TLS) collapses to one
line; the full error is logged for operators.
Slack tokens and webhook headers were returned in plaintext by
`integration.list`/`get` at `read` — bare project membership. The Slack bot
token and incoming-webhook URL are both bearer credentials for the customer's
workspace, and webhook header values routinely carry an Authorization bearer.
Both are now redacted.
These are read-only exposures of values the notification senders consume raw,
so they are redact-only: encrypting them would need decrypt-at-use plus a
backfill of the existing plaintext rows. `secretFields` therefore became a
descriptor — `path` (dotted, for incoming_webhook.url), `encrypted` (also
encrypt at rest, reject a replayed ciphertext, require non-blank) and `record`
(a map whose values are secret, keys stay visible so the form still shows which
headers are set, carry-over per key so editing a webhook no longer wipes its
auth header).
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy1 parent 247744a commit 663959d
65 files changed
Lines changed: 5290 additions & 578 deletions
File tree
- apps
- api
- src/controllers
- start/src
- components
- integrations
- forms
- modals
- routes
- worker
- src
- jobs
- packages
- common/server
- db
- code-migrations
- prisma
- migrations
- 20260828130000_export_watermarks
- 20260828130100_integration_project_scope
- src
- exports
- services
- integrations
- src
- object-store
- queue/src
- trpc/src/routers
- validation/src
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
11 | 22 | | |
12 | 23 | | |
13 | 24 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
80 | 85 | | |
81 | 86 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
29 | 32 | | |
30 | 33 | | |
31 | 34 | | |
| |||
87 | 90 | | |
88 | 91 | | |
89 | 92 | | |
90 | | - | |
| 93 | + | |
91 | 94 | | |
92 | 95 | | |
93 | 96 | | |
| |||
102 | 105 | | |
103 | 106 | | |
104 | 107 | | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
105 | 114 | | |
106 | | - | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
107 | 118 | | |
108 | 119 | | |
109 | 120 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
13 | 17 | | |
14 | 18 | | |
15 | 19 | | |
| |||
Lines changed: 3 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
33 | | - | |
| 33 | + | |
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| |||
Lines changed: 13 additions & 7 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
8 | 7 | | |
9 | 8 | | |
10 | 9 | | |
| |||
21 | 20 | | |
22 | 21 | | |
23 | 22 | | |
24 | | - | |
| 23 | + | |
25 | 24 | | |
26 | 25 | | |
27 | 26 | | |
28 | 27 | | |
29 | | - | |
| 28 | + | |
30 | 29 | | |
31 | 30 | | |
32 | 31 | | |
| |||
55 | 54 | | |
56 | 55 | | |
57 | 56 | | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
58 | 61 | | |
59 | | - | |
60 | | - | |
| 62 | + | |
| 63 | + | |
61 | 64 | | |
62 | 65 | | |
63 | | - | |
64 | | - | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
65 | 71 | | |
66 | 72 | | |
67 | 73 | | |
| |||
Lines changed: 192 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
0 commit comments