diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index 93238e7..e0cf0aa 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -1,6 +1,6 @@ --- name: "\U0001F41B Bug report" -about: Something isn't working? Create a report to help us improve Flyer Chat. +about: Something isn't working? Create a report to help us improve Flutter Supabase Chat Core. title: '' labels: bug assignees: '' @@ -75,4 +75,4 @@ OS version (e.g. iOS 14.5, Android 11): ... Add any other context about the problem here. - \ No newline at end of file + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md index 06242a4..a8e1530 100644 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ b/.github/ISSUE_TEMPLATE/---feature-request.md @@ -1,6 +1,6 @@ --- name: "⚡️ Feature request" -about: Suggest an idea to help make Flyer Chat even better! +about: Suggest an idea to help make Flutter Supabase Chat Core even better! title: '' labels: feature assignees: '' diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b9cc44e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: ['*'] + + - package-ecosystem: pub + directory: / + schedule: + interval: weekly + + - package-ecosystem: pub + directory: /example + schedule: + interval: weekly + groups: + example: + patterns: ['*'] + + # The documentation site (Docusaurus): build-time only. + - package-ecosystem: npm + directory: /doc + schedule: + interval: weekly + groups: + docusaurus: + patterns: ['*'] diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..84a95cc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,117 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze and unit tests (Flutter ${{ matrix.flutter }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The minimum version declared in pubspec.yaml, and the latest stable. + flutter: ['3.35.0', 'stable'] + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ matrix.flutter != 'stable' && matrix.flutter || '' }} + channel: stable + cache: true + + # The example app may need a newer SDK than the package minimum: on the + # minimum version only the package itself is resolved and analyzed. + - name: Install dependencies + run: flutter pub get ${{ matrix.flutter != 'stable' && '--no-example' || '' }} + + # Formatting output can differ between Dart versions: checked on the + # latest stable only. + - name: Check formatting + if: matrix.flutter == 'stable' + run: dart format . --set-exit-if-changed + + - name: Analyze + run: flutter analyze --no-pub ${{ matrix.flutter != 'stable' && 'lib test' || '' }} + + # The integration tests (tag `integration`) need the Supabase stack: + # see the integration job. + - name: Unit tests + run: flutter test --no-pub --exclude-tags integration --coverage + + # The 10 points of the example/screenshots check are only granted on + # pub.dev: any other lost point fails the job. + - name: pub.dev score (pana) + if: matrix.flutter == 'stable' + run: | + dart pub global activate pana + pana --no-warning --exit-code-threshold 10 . + + integration: + name: Database and integration tests + runs-on: ubuntu-latest + needs: analyze + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - uses: supabase/setup-cli@v1 + with: + version: 2.117.0 + + - name: Install dependencies + run: flutter pub get + + - name: Start the local Supabase stack + run: supabase start + + # Applies example/utils/sql exactly as end users do. + - name: Apply the chat schema + run: bash tool/local_db.sh + + - name: Database tests (pgTAP) + run: supabase test db + + - name: Integration tests + run: bash tool/test_integration.sh --no-reset + + - name: Stop the local Supabase stack + if: always() + run: supabase stop --no-backup + + docs: + name: Documentation site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: doc/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: doc + + # onBrokenLinks: 'throw' fails the build on a broken link. + - name: Build + run: npm run build + working-directory: doc diff --git a/.github/workflows/deploy-pages.yaml b/.github/workflows/deploy-pages.yaml index 459377b..ee52906 100644 --- a/.github/workflows/deploy-pages.yaml +++ b/.github/workflows/deploy-pages.yaml @@ -1,34 +1,57 @@ -name: Deploy GitHub pages +name: Deploy documentation on: push: branches: [main] + paths: + - 'doc/**' + - 'CNAME' + - '.github/workflows/deploy-pages.yaml' + workflow_dispatch: + +# Requires Settings > Pages > Source: "GitHub Actions". +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false jobs: - deploy: + build: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: 20 + cache: npm + cache-dependency-path: doc/package-lock.json - name: Install dependencies - run: npm install + run: npm ci working-directory: doc - - name: Build site + - name: Build run: npm run build working-directory: doc - - name: Add CNAME file - run: cp ./CNAME ./doc/build/CNAME + - name: Add CNAME + run: cp CNAME doc/build/CNAME - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 + - uses: actions/upload-pages-artifact@v3 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./doc/build \ No newline at end of file + path: doc/build + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/flutter-analyze.yaml b/.github/workflows/flutter-analyze.yaml deleted file mode 100644 index de08d68..0000000 --- a/.github/workflows/flutter-analyze.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: Flutter analyze - -on: - pull_request: - branches: [main, dev] - -jobs: - flutter-analyze: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Setup Flutter environment - uses: subosito/flutter-action@v2 - with: - channel: 'stable' - - - name: Install dependencies - run: flutter pub get - - - name: Analyze formatting - run: dart format . --set-exit-if-changed - - - name: Analyze project source - run: flutter analyze --no-pub - - # Unit tests only: the integration tests (tag `integration`) need a - # running Supabase stack, see tool/test_integration.sh. - - name: Run unit tests - run: flutter test --no-pub --exclude-tags integration - - - name: Install pana - run: dart pub global activate pana - - - name: Analyze project source with pana - run: pana diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..01dfb8c --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,49 @@ +name: Publish to pub.dev + +# Runs when a version tag is pushed (e.g. v2.0.0, v2.0.0-rc.3), then waits for +# a manual approval: the `pub.dev` environment must have required reviewers +# (Settings > Environments). Authentication is GitHub OIDC, no stored secret: +# on pub.dev, Admin > Automated publishing must enable GitHub Actions for +# this repository with tag pattern `v{{version}}` and require the `pub.dev` +# environment. +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: pub.dev + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + # Configures the OIDC token pub.dev accepts. + - uses: dart-lang/setup-dart@v1 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Check the tag matches pubspec.yaml + run: | + version="$(grep -E '^version:' pubspec.yaml | awk '{print $2}')" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match pubspec.yaml version ${version}" + exit 1 + fi + + - name: Install dependencies + run: flutter pub get + + - name: Dry run + run: dart pub publish --dry-run + + - name: Publish + run: dart pub publish --force diff --git a/CHANGELOG.md b/CHANGELOG.md index 116c2ad..fbe53dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,79 @@ +## [2.0.0-rc.3] - 2026-10-23 +#### [@rickypid](https://github.com/rickypid) + +Third release candidate of 2.0.0: security fixes from an audit of the +package. Re-run the SQL scripts (`01`..`05`, `99`, and `06` if you use it) +and update the package together: the Realtime protocol changed, see +[MIGRATION.md](MIGRATION.md#upgrading-from-a-200-release-candidate). + +### Security + +* A message `source` could make the `cleanup-media` Edge Function delete any + Storage object of the project (other rooms, avatars, other buckets) with the + service role: the media queue only accepts a `chats_assets` object in the + deleted message's own room folder, not referenced by another message, and + the function checks every row again. +* `httpSupabaseHeaders` sent the access token to any URL, and the example and + the docs attached it to avatars and message sources other users choose: + `httpSupabaseHeadersFor(url)` sends it only to this project's Storage; + `httpSupabaseHeaders` is deprecated. +* A member of a direct room could add third parties to it, or turn it into a + group, through unrestricted `UPDATE`s of `chats.room_members`/`chats.rooms`: + `UPDATE` is granted per column (rooms: name, image, metadata; members: role; + profiles: no global `role`) and role changes are for group admins. +* Members could forge message events (fake, edited or hidden messages) on the + room's Realtime channel: only the database sends on `chats:room:`, + the typing indicator moved to `chats:room::typing`. +* Reusing another message's id let a member pin or rewrite reactions of + messages of other rooms, or get an admin deleting their copy to delete the + original too: message ids are unique (`chats.message_ids`) and the RPCs + update exactly the row they authorized. +* Any member can no longer overwrite or delete another member's attachment: + `chats_assets` objects are updated by their uploader, deleted by their + uploader or a group admin. +* Online status could be faked or watched with the anon key: presence + channels are private, any signed-in user may watch, only the owner + publishes. +* Room and membership changes travel on a private per-user channel + (`chats:user:`) instead of Postgres Changes, whose `DELETE` events told + every subscriber who left which room; a member removed from a room is + disconnected from its channels right away. +* The forward origin (`forwardedFrom`) is attested by the database from the + forwarded message instead of being written by the client. +* Message time is assigned by the database: a client `createdAt` far from its + clock is rejected (a far-future date blocked the partition maintenance and + stayed the room's last message), and it can't be changed afterwards. +* Authors edit the content of their messages only: status, reactions and pin + change through the RPCs, `updatedAt` is set by the database. + +### Fixed + +* The `partman-run-maintenance` cron job always failed (its command shared a + transaction block with a `SELECT`): the procedure runs alone, and a separate + `chats-messages-partitions-rls` job plus an event trigger secure new + `chats.messages` partitions as soon as they are created. +* `cleanup-media` retries a failing object a few times and then leaves it + aside (`attempts`, `lastError`) instead of blocking the queue. +* `retryMessage` of a message that did reach the server marks it sent. +* The example app saves downloaded files under a sanitized name and asks + before opening them (documents and media only). + +### Changed + +* Minimum SDKs raised to `Dart >=3.9.0` and `Flutter >=3.35.0`: the + `supabase_flutter` versions the package depends on (>=2.16.0) already + required Dart 3.9, so the previous minimum couldn't resolve. +* `sendMessage` and `forwardMessages` let the database set `createdAt`. +* `ChatForwardOrigin.claim` builds the forward request; the stored origin is + read with `ChatForwardOrigin.of` as before. +* `SupabaseChatCoreConfig.realtimeUserChannelPrefix` (`chats:user:`). +* `chats.rooms` and `chats.room_members` are no longer in the + `supabase_realtime` publication. +* CI on GitHub Actions: analysis on the minimum and latest Flutter, pana, + unit, database (pgTAP) and integration tests on a local Supabase stack, and + the documentation build; publishing to pub.dev from a version tag after a + manual approval; Dependabot. + ## [2.0.0-rc.2] - 2026-10-22 #### [@rickypid](https://github.com/rickypid) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aa5275..a68af29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to Flyer Chat +# Contributing to Flutter Supabase Chat Core -Thank you for your interest in contributing to Flyer Chat! Our vision is to create an easy-to-use chat experience for any application and while we can't have everything right from the start, we can build it together! If you plan to implement a feature and open a pull request, make sure you don't spend a lot of time on it, otherwise open an issue so we can discuss bigger stuff. +Thank you for your interest in contributing to Flutter Supabase Chat Core! Our vision is to create an easy-to-use chat experience for any application and while we can't have everything right from the start, we can build it together! If you plan to implement a feature and open a pull request, make sure you don't spend a lot of time on it, otherwise open an issue so we can discuss bigger stuff. The [Open Source Guides](https://opensource.guide) website has a collection of resources for individuals, communities, and companies who want to learn how to run and contribute to an open-source project. Contributors and people new to open source alike will find the following guides especially useful: @@ -13,14 +13,14 @@ We use [Docusaurus](https://docusaurus.io) to build our documentation which is l ## Contributing Code -Code-level contributions to Flyer Chat generally come in the form of pull requests. These are done by forking the repo and making changes locally. Directly in the repo, there is the [example](https://github.com/insideapp-srl/flutter_supabase_chat_core/tree/main/example) project that you can install on your device (or simulators) and use to test the changes you're making to Flyer Chat sources. +Code-level contributions to Flutter Supabase Chat Core generally come in the form of pull requests. These are done by forking the repo and making changes locally. Directly in the repo, there is the [example](https://github.com/insideapp-srl/flutter_supabase_chat_core/tree/main/example) project that you can install on your device (or simulators) and use to test the changes you're making to the library sources. [Here](https://opensource.guide/how-to-contribute/#opening-a-pull-request) you can read how to open a pull request. ## Community Contributions -Contributions to Flyer Chat are not limited to GitHub. You can help others by sharing your experience using Flyer Chat, whether that is through blog posts, presenting talks at conferences, or simply sharing your thoughts on social media. +Contributions to Flutter Supabase Chat Core are not limited to GitHub. You can help others by sharing your experience using Flutter Supabase Chat Core, whether that is through blog posts, presenting talks at conferences, or simply sharing your thoughts on social media. ## License -By contributing to Flyer Chat, you agree that your contributions will be licensed under its [Apache License, Version 2.0](LICENSE). \ No newline at end of file +By contributing to Flutter Supabase Chat Core, you agree that your contributions will be licensed under its [Apache License, Version 2.0](LICENSE). \ No newline at end of file diff --git a/LICENSE b/LICENSE index babf357..4cc956d 100644 --- a/LICENSE +++ b/LICENSE @@ -187,6 +187,7 @@ identification within third-party archives. Copyright 2023 Oleksandr Demchenko + Copyright 2024-2026 InsideApp S.r.l. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/MIGRATION.md b/MIGRATION.md index fc7c5d0..ec17a06 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -19,7 +19,7 @@ reference while porting. ```yaml dependencies: - flutter_supabase_chat_core: ^2.0.0-rc.2 + flutter_supabase_chat_core: ^2.0.0-rc.3 flutter_chat_core: ^2.9.0 # replaces flutter_chat_types flutter_chat_ui: ^2.11.1 # if you use the UI (2.x API) flyer_chat_text_message: ^2.6.0 # one package per message type you render @@ -28,7 +28,7 @@ dependencies: uuid: ^4.5.1 ``` -Minimum SDKs are now `Dart >=3.8.0` and `Flutter >=3.32.0`. +Minimum SDKs are now `Dart >=3.9.0` and `Flutter >=3.35.0`. `flutter_chat_ui` 2.12.0 moved its `Composer` to the experimental `material_ui` package, which crashes inside a standard `Scaffold` ("No @@ -336,3 +336,31 @@ script moves them as well, delete them yourself if you don't need them. 8. Catch `SupabaseChatCoreException` instead of Supabase exceptions. 9. Migrate the database during a maintenance window, verify, then release. 10. Move the 1.x attachments with `migrate_storage_uuids.sh`/`.ps1`. +11. Load stored URLs with `httpSupabaseHeadersFor(url)` instead of + `httpSupabaseHeaders`, which sends the access token to any URL. + +## Upgrading from a 2.0.0 release candidate + +The security fixes after `2.0.0-rc.2` change the database and the Realtime +protocol together: deploy the new SQL scripts and the new package version at +the same time (re-running `example/utils/sql/01..05,99` is safe on an rc +database, every change is idempotent). + +- Typing events moved to their own channel (`chats:room::typing`): + clients can no longer send on the message channel. +- Room and membership changes travel on a private per-user channel + (`chats:user:`) instead of Postgres Changes; `chats.rooms` and + `chats.room_members` leave the `supabase_realtime` publication. +- Online status channels are private: any signed-in user may watch them, + only their owner may publish. +- The database assigns message times and rejects a client `createdAt` far + from its clock; message ids must be unique; status, reactions, pin and the + forward origin are database-owned. +- Only the uploader may overwrite an attachment, only the uploader or a group + admin may delete it. +- The `partman-run-maintenance` cron job now runs the procedure alone (the + combined command always failed), and a new `chats-messages-partitions-rls` + job secures new partitions. + +An rc client against the new database loses the typing indicator and the +rooms list updates, and fails to send while its device clock is off. diff --git a/README.md b/README.md index 017dd5d..16e2f22 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Flyer Chat is a platform for creating in-app chat experiences using Flutter. Thi ## Requirements -- `Dart >=3.8.0` and `Flutter >=3.32.0`. +- `Dart >=3.9.0` and `Flutter >=3.35.0`. - A [Supabase](https://supabase.com) project (cloud or self-hosted) with: - the `pg_partman` and `pg_cron` extensions available (the SQL scripts create them); - Realtime with **Broadcast from Database** support (Supabase cloud, or self-hosted `supabase/realtime` >= v2.33): message changes are sent by a database trigger; @@ -110,7 +110,7 @@ Flyer Chat is a platform for creating in-app chat experiences using Flutter. Thi ```yaml dependencies: - flutter_supabase_chat_core: ^2.0.0-rc.2 + flutter_supabase_chat_core: ^2.0.0-rc.3 supabase_flutter: ^2.17.2 uuid: ^4.5.1 @@ -378,7 +378,7 @@ Call `onTyping()` on every keystroke of the composer, e.g. with your own `TextEd | Members | `addMembers()`, `removeMember()`, `leaveRoom()`, `setMemberRole()` | | Users | `users(filter:, excludeLoggedUser:)`, `user()`, `updateUser()`, `uploadUserAvatar()`, `loggedUser`, `loggedUserId`, `refreshLoggedUser()` | | Messages | `messages(roomId, before:, limit:)`, `message()`, `pinnedMessages()`, `searchMessages()`, `sendMessage()`, `forwardMessages()`, `updateMessage()`, `toggleReaction()`, `setMessagePinned()`, `deleteMessage()` | -| Storage | `uploadAsset()`, `getAssetUrl()`, `httpSupabaseHeaders` (to load authenticated URLs) | +| Storage | `uploadAsset()`, `getAssetUrl()`, `httpSupabaseHeadersFor(url)` (to load authenticated URLs: the token only goes to this project's Storage) | | Presence | `setPresenceStatus()`, `userOnlineStatus()` | | Session | `closeSession()` (before signing out), `dispose()` | @@ -435,13 +435,13 @@ Hooks run on every call, so they can read state that changes at runtime. **They | What | Transport | |------|-----------| -| Message inserts/updates/deletes | Private **Broadcast** channel `chats:room:`, sent by the `chats.broadcast_message_change` trigger (`realtime.broadcast_changes`). | -| Typing indicator | Same channel, Broadcast events sent by clients. | -| Rooms list, single room | **Postgres Changes** on `chats.rooms` and `chats.room_members`. | -| Online status | **Presence** channel `online-user-`. | +| Message inserts/updates/deletes | Private **Broadcast** channel `chats:room:`, sent by the `chats.broadcast_message_change` trigger (`realtime.broadcast_changes`); members receive, nobody sends. | +| Typing indicator | Private **Broadcast** channel `chats:room::typing`, events sent by members. | +| Rooms list, single room | Private **Broadcast** channel `chats:user:`, sent by triggers on `chats.rooms` and `chats.room_members` to every member. | +| Online status | Private **Presence** channel `online-user-`: any signed-in user watches, only `` publishes. | - Postgres Changes can't be used for messages: `chats.messages` is partitioned and Postgres Changes reports rows under their physical partition, so a subscription on the parent table never matches. Broadcast also authorizes once at join time instead of checking RLS per change per subscriber. -- Room channels are private: RLS policies on `realtime.messages` only let room members join (receive) and send. One channel per room is shared by every controller open on it; a channel refused as unauthorized is released and not retried. +- Every channel is private, authorized by RLS policies on `realtime.messages`. Since only the database sends on the message channel, a member can't forge message events for others; Postgres Changes aren't used for the rooms list because Realtime doesn't apply RLS to `DELETE` events. The channels of a room are shared by every controller open on it; a channel refused as unauthorized is released and not retried, and the channels are closed as soon as the user is removed from the room. - The typing indicator moved from Presence to Broadcast ([#38](https://github.com/insideapp-srl/flutter_supabase_chat_core/issues/38)): Presence `track()` calls are rate-limited and the channel was dropped while typing. Each client sends `typing: true` at most every **2 s**, `typing: false` after **3 s** of inactivity (or on send), and receivers drop a user without a fresh event after **5 s**, so a lost "stopped" event can't leave a stale indicator. Trade-off: Broadcast keeps no state, so someone opening the room while another user is already typing sees the indicator from that user's next event (at most 2 s later). - Online status: every `userOnlineStatus(uid)` listener of the same user shares one Presence channel, closed when the last listener cancels; the stream emits the current status first. @@ -482,13 +482,13 @@ Both widgets take an optional `chatCore:` (defaults to `SupabaseChatCore.instanc | Table | INSERT | SELECT | UPDATE | DELETE | |-------|--------|--------|--------|--------| -| `chats.users` | Nobody (created by the sign-up trigger) | Authenticated users | The user himself | Nobody (deleted with `auth.users`) | -| `chats.rooms` | Nobody (only through `chats.create_room`) | Members | Admins | Admins | -| `chats.room_members` | Group admins, group rooms only (no self-join) | Members of the same room | Admins (roles) | Yourself (leave) or admins (remove) | -| `chats.messages` | Members, as themselves only (`authorId = auth.uid()`) | Members | The author | The author, or a group admin | -| `realtime.messages` (topics `chats:room:*`) | Members (Broadcast only) | Members (join the channel) | – | – | +| `chats.users` | Nobody (created by the sign-up trigger) | Authenticated users | The user himself (not the global `role`) | Nobody (deleted with `auth.users`) | +| `chats.rooms` | Nobody (only through `chats.create_room`) | Members | Admins (name, image, metadata) | Admins | +| `chats.room_members` | Group admins, group rooms only (no self-join) | Members of the same room | Group admins (role only) | Yourself (leave) or admins (remove) | +| `chats.messages` | Members, as themselves only (`authorId = auth.uid()`) | Members | The author (content only) | The author, or a group admin | +| `realtime.messages` | Typing: members; presence: the user on their own channel | Room channels: members; `chats:user:`: the user; presence: signed-in users | – | – | -UPDATE policies state their rule once, in `USING`: without `WITH CHECK`, Postgres applies it to the new row too, so a row can't be moved out of the rule. +UPDATE policies state their rule once, in `USING`: without `WITH CHECK`, Postgres applies it to the new row too, so a row can't be moved out of the rule. Which columns can be updated is limited by column privileges (`99_database_schema_permission.sql`) and, for messages, by triggers: ids are unique, the time is set by the database, status/reactions/pin change through the RPCs and the forward origin is attested from the forwarded message. Operations touching rows of other users go through `SECURITY DEFINER` RPCs that check membership themselves: @@ -502,18 +502,18 @@ Operations touching rows of other users go through `SECURITY DEFINER` RPCs that ### Storage buckets -Both buckets are private: files are served through authenticated URLs (`getAssetUrl`, loaded with `httpSupabaseHeaders`). +Both buckets are private: files are served through authenticated URLs (`getAssetUrl`, loaded with `httpSupabaseHeadersFor(url)`). | Bucket | INSERT | SELECT | UPDATE | DELETE | |--------|--------|--------|--------|--------| -| `chats_assets` (`/`) | Room members | Room members | Room members | Room members | +| `chats_assets` (`/`) | Room members | Room members | The uploader | The uploader, or a group admin | | `chats_user_avatar` (`/`) | The user himself | Authenticated users | The user himself | The user himself | Attachments uploaded by 1.x live under the old numeric room id folder: they stay readable through `chats.room_id_migration_map` until `example/utils/migrate_storage_uuids.sh`/`.ps1` moves them to the room uuid folder (see [MIGRATION.md](MIGRATION.md)). ## Media cleanup -Deleting a message deletes its row immediately. When it referenced a Storage file, the `chats.queue_media_for_deletion` trigger records the object in `chats.media_pending_deletion` (closed to clients), because SQL alone can't delete objects from the storage backend. The [`cleanup-media`](supabase/functions/cleanup-media/index.ts) Edge Function drains that queue (500 objects per call) through the Storage API. +Deleting a message deletes its row immediately. When it referenced one of its own attachments (a `chats_assets` object in the message's room folder, not referenced by another message), the `chats.queue_media_for_deletion` trigger records the object in `chats.media_pending_deletion` (closed to clients), because SQL alone can't delete objects from the storage backend. The [`cleanup-media`](supabase/functions/cleanup-media/index.ts) Edge Function drains that queue (500 objects per call) through the Storage API, checking every row again, and leaves aside an object that keeps failing instead of blocking the queue. 1. Deploy it: `supabase functions deploy cleanup-media`. 2. Store the project URL and a service role key in Vault, once: @@ -535,7 +535,7 @@ Without it nothing breaks: deleted messages' files simply stay in the bucket. | Integration tests against a local Supabase stack | `supabase start` once, then `tool/test_integration.sh` | | Database behaviour tests (pgTAP) | `tool/local_db.sh && supabase test db` | -- `tool/local_db.sh` resets the local database, applies the SQL scripts exactly like end users do and restarts the Realtime container (after a reset its Postgres Changes poller can stay degraded). `--no-reset` applies them on top of the current database, to check they are idempotent. +- `tool/local_db.sh` resets the local database, applies the SQL scripts exactly like end users do and restarts the Realtime container (after a reset it can stay degraded). `--no-reset` applies them on top of the current database, to check they are idempotent. - `tool/test_integration.sh` runs `tool/local_db.sh`, reads the local URL and keys from `supabase status` and runs `flutter test test/integration --tags integration`. Pass `--no-reset` to keep the database. - `tool/v1_fixture.sh` rebuilds the local database in the 1.x shape (scripts from the `main` branch, `V1_REF` to change it) with sample data; follow it with `tool/local_db.sh --no-reset` to exercise the migration. - `tool/seed_demo.sh` seeds demo users and conversations (incl. a voice message): sign in as `alice@demo.dev` / `Demo-password-1` (also `bob@`, `carol@`, `dave@`, `erin@demo.dev`). diff --git a/doc/README.md b/doc/README.md index 0c6c2c2..85761c1 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,41 +1,33 @@ -# Website +# Documentation site -This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. +Source of https://flutter-supabase-chat-core.insideapp.it, built with +[Docusaurus](https://docusaurus.io/). Pages live in `docs/`, the sidebar order +in `sidebars.ts`. ### Installation ``` -$ yarn +$ npm ci ``` -### Local Development +### Local development ``` -$ yarn start +$ npm start ``` -This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. +Starts a local development server with live reload. ### Build ``` -$ yarn build +$ npm run build ``` -This command generates static content into the `build` directory and can be served using any static contents hosting service. +Generates the static site into `build/`. ### Deployment -Using SSH: - -``` -$ USE_SSH=true yarn deploy -``` - -Not using SSH: - -``` -$ GIT_USER= yarn deploy -``` - -If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. +The site is deployed to GitHub Pages by the `Deploy GitHub pages` workflow +(`.github/workflows/deploy-pages.yaml`) on every push to `main`; no manual +deploy is needed. diff --git a/doc/docs/guides/supabase-realtime.md b/doc/docs/guides/supabase-realtime.md index 6cce82d..2201aec 100644 --- a/doc/docs/guides/supabase-realtime.md +++ b/doc/docs/guides/supabase-realtime.md @@ -3,18 +3,20 @@ id: supabase-realtime title: Realtime --- -The package uses all three [Supabase Realtime](https://supabase.com/docs/guides/realtime) features, each where it fits best: +The package uses [Supabase Realtime](https://supabase.com/docs/guides/realtime) Broadcast and Presence, always on **private** channels authorized by RLS policies on `realtime.messages`: -| Data | Feature | Channel | -|------|---------|---------| -| Message inserts, updates, deletes | Broadcast from Database | private `chats:room:` | -| Typing indicator | Broadcast (client to client) | same private channel | -| Rooms list (`roomsChanges()`, `SupabaseChatRoomsController`) and single room (`room(id)`) | Postgres Changes on `chats.rooms` and `chats.room_members` | one per subscription | -| Online status | Presence | `online-user-` | +| Data | Feature | Channel | Who receives | Who sends | +|------|---------|---------|--------------|-----------| +| Message inserts, updates, deletes | Broadcast from Database | `chats:room:` | room members | the database only | +| Typing indicator | Broadcast (client to client) | `chats:room::typing` | room members | room members | +| Rooms list (`roomsChanges()`, `SupabaseChatRoomsController`) and single room (`room(id)`) | Broadcast from Database | `chats:user:` | that user only | the database only | +| Online status | Presence | `online-user-` | any signed-in user | that user only | + +Postgres Changes are not used: `chats.messages` is partitioned (see below), and Realtime doesn't apply RLS to `DELETE` events, so publishing `chats.rooms`/`chats.room_members` told every subscriber who left which room. `01_database_schema.sql` removes those tables from the `supabase_realtime` publication. ## Room channels -Every message change is sent to the room's private channel by the `chats.broadcast_message_change` trigger, which calls `realtime.broadcast_changes` with the operation (`INSERT`, `UPDATE`, `DELETE`) as event name and the new and old rows as payload. `SupabaseChatController` listens to those events and applies them to its list. +Every message change is sent to the room's channel by the `chats.broadcast_message_change` trigger, which calls `realtime.broadcast_changes` with the operation (`INSERT`, `UPDATE`, `DELETE`) as event name and the new and old rows as payload. `SupabaseChatController` listens to those events and applies them to its list. Why not Postgres Changes on `chats.messages`: @@ -22,13 +24,15 @@ Why not Postgres Changes on `chats.messages`: * Broadcast authorizes once, when the channel is joined, instead of evaluating RLS for every change and every subscriber; * a `DELETE` broadcast carries the whole old row, so it can be routed to its room. +Members can't send on the message channel: every event on it comes from the database, so a member can't make other members' apps show a message that was never stored, rewrite someone else's message or hide one. The typing indicator, the only thing clients send, has its own `chats:room::typing` channel. + A failure to broadcast never loses the message: the trigger reports it as a Postgres `WARNING` and the write goes through. Requirements: Realtime with Broadcast from Database support (Supabase cloud, or self-hosted `supabase/realtime` >= v2.33). ### Authorization -Room channels are private (`RealtimeChannelConfig(private: true)`). Realtime authorizes them against RLS policies on `realtime.messages`, created by `03_database_policy.sql`: +Realtime authorizes joining a private channel (receiving broadcasts and presence) against `SELECT` policies on `realtime.messages`, and sending (broadcast, presence tracking) against `INSERT` policies. `03_database_policy.sql` creates them: ```sql CREATE POLICY "chats.realtime_room_read" @@ -36,7 +40,11 @@ CREATE POLICY "chats.realtime_room_read" AS PERMISSIVE FOR SELECT TO authenticated - USING (chats.is_chat_member_topic((SELECT realtime.topic()))); + USING ( + chats.is_chat_member_topic((SELECT realtime.topic())) + OR chats.is_own_user_topic((SELECT realtime.topic())) + OR chats.is_presence_topic((SELECT realtime.topic())) + ); CREATE POLICY "chats.realtime_room_write" ON realtime.messages @@ -44,24 +52,24 @@ CREATE POLICY "chats.realtime_room_write" FOR INSERT TO authenticated WITH CHECK ( - extension = 'broadcast' - AND chats.is_chat_member_topic((SELECT realtime.topic())) + (extension = 'broadcast' AND chats.is_chat_typing_topic((SELECT realtime.topic()))) + OR (extension = 'presence' AND chats.is_own_presence_topic((SELECT realtime.topic()))) ); ``` -`chats.is_chat_member_topic` only matches topics starting with `chats:room:` followed by a valid uuid, so policies other apps define on `realtime.messages` are unaffected and malformed topics are simply refused. +The helpers only match the topic shapes above with a valid uuid, so policies other apps define on `realtime.messages` are unaffected and malformed topics are simply refused. ### Sharing and lifecycle -Realtime allows a single join per topic per connection, so a `SupabaseChatCore` keeps one channel per room, shared by every controller open on it, and releases it when the last one is disposed. When Realtime refuses a channel as unauthorized (the user isn't, or is no longer, a member) the channel is released and not retried, instead of rejoining with backoff for as long as the connection lives; the controller reports the failure through `onError`. +Realtime allows a single join per topic per connection, so a `SupabaseChatCore` keeps one pair of channels per room (messages and typing), shared by every controller open on it, and releases them when the last one is disposed. When Realtime refuses a channel as unauthorized the channels are released and not retried, instead of rejoining with backoff for as long as the connection lives; the controller reports the failure through `onError`. -The Postgres Changes subscriptions behind `room(id)` and `roomsChanges()` use a unique topic per subscription, so several listeners of the same data can coexist. A failed or timed-out subscription is surfaced as a `SupabaseChatCoreRequestException` on the stream. +Realtime checks a private channel's policies when it is joined. So that a member removed from a room doesn't keep receiving its messages on an already open channel, the database tells the removed user on their user channel (`removed: true`, see below) and the package closes the room's channels right away, reporting it to the controllers through `onError`. A client that ignores that event keeps the channel until Realtime re-authorizes it (on a new join or token refresh): keep the JWT expiry short if that window matters to you. -Everything above belongs to the signed-in user's session. `closeSession()`, a sign out or a switch to another user removes the room channels, the presence watches and the subscriptions behind `room(id)`, `roomsChanges()` and `userOnlineStatus()`, whose streams complete. Call `closeSession()` before signing out: once signed out, Realtime is re-authorized with the anon key and private room channels would fail instead of leaving cleanly. Auth state changes arrive in bursts (initial session, signed in, token refreshed) and are handled one at a time, so the own presence channel is opened once; status callbacks of channels already released are ignored. +Everything above belongs to the signed-in user's session. `closeSession()`, a sign out or a switch to another user removes the room channels, the user channel, the presence watches and the subscriptions behind `room(id)`, `roomsChanges()` and `userOnlineStatus()`, whose streams complete. Call `closeSession()` before signing out: once signed out, Realtime is re-authorized with the anon key and private channels would fail instead of leaving cleanly. Auth state changes arrive in bursts (initial session, signed in, token refreshed) and are handled one at a time, so the own presence channel is opened once; status callbacks of channels already released are ignored. ## Typing indicator -The typing indicator used Realtime Presence up to 1.x. Supabase rate-limits Presence `track()` calls per client and drops the channel when the limit is exceeded, which a typing indicator updating every few hundred milliseconds hits within seconds ([issue #38](https://github.com/insideapp-srl/flutter_supabase_chat_core/issues/38)). Since 2.x it uses Broadcast events on the room channel, which are cheap and ephemeral. +The typing indicator used Realtime Presence up to 1.x. Supabase rate-limits Presence `track()` calls per client and drops the channel when the limit is exceeded, which a typing indicator updating every few hundred milliseconds hits within seconds ([issue #38](https://github.com/insideapp-srl/flutter_supabase_chat_core/issues/38)). Since 2.x it uses Broadcast events on the room's typing channel, which are cheap and ephemeral. The protocol, per user: @@ -69,23 +77,28 @@ The protocol, per user: * a `{uid, typing: false}` event after **3 seconds** without keystrokes, when a message is sent, or when the last controller of the room is disposed; * receivers drop a user who hasn't sent `typing: true` within **5 seconds**, so a lost "stopped" event (app killed, connection lost) can never leave a stale indicator behind. +The `uid` of a typing event is chosen by the sending member (Broadcast doesn't tell receivers who sent an event): treat it as display data only. + Trade-off: Broadcast keeps no state. Someone opening a room while another member is already typing sees the indicator only from that member's next event, at most 2 seconds later. ## Rooms list -`roomsChanges()` subscribes to: +Room and membership changes reach each member on their own user channel `chats:user:`, which only that user may join. Statement-level triggers on `chats.rooms` and `chats.room_members` call `chats.send_room_change`, which sends a `room_change` event with `{roomId, removed}`: -* `UPDATE`/`DELETE` on `chats.rooms` (a new message updates the room's `lastMessages` and `updatedAt`); -* `INSERT` on `chats.room_members` for the current user (added to a room); -* `DELETE` on `chats.room_members` (the current user left or was removed, or another member left). +* to every member of a room when the room is updated (e.g. a new message updates its `lastMessages` and `updatedAt`), someone joins or leaves it, or a role changes; +* only to the member concerned when their own per-member columns change (`lastReadAt`, `pinnedAt`, `mutedUntil`); +* with `removed: true` to the user who left, was removed, or whose room was deleted. -Each relevant event refetches the room from `chats.rooms_l` (so the unread count and the caller's preferences are fresh) and emits a `ChatRoomUpserted`, or a `ChatRoomRemoved` when the room is gone or no longer visible. Rooms rejected by `SupabaseChatCoreConfig.roomFilter` are skipped. Postgres Changes still checks RLS, so users only receive events about rooms they can read. +`roomsChanges()` refetches the room from `chats.rooms_l` on each event (so the unread count and the caller's preferences are fresh) and emits a `ChatRoomUpserted`, or a `ChatRoomRemoved` when `removed` is true or the room is no longer visible; `room(id)` refetches it and completes when it's gone. Rooms rejected by `SupabaseChatCoreConfig.roomFilter` are skipped. A failed or timed-out subscription is surfaced as a `SupabaseChatCoreRequestException` on the stream. ## Online status -`UserOnlineStateObserver` tracks the logged-in user on the Presence channel `online-user-` while the app is in the foreground and untracks it otherwise; going offline also stores `chats.users."lastSeen"`. `userOnlineStatus(uid)` (and `UserOnlineStatusWidget`) listen to that channel: every listener of the same user shares one channel, closed when the last listener cancels, and the stream emits the current status first. +`UserOnlineStateObserver` tracks the logged-in user on the private Presence channel `online-user-` while the app is in the foreground and untracks it otherwise; going offline also stores `chats.users."lastSeen"`. `userOnlineStatus(uid)` (and `UserOnlineStatusWidget`) listen to that channel: every listener of the same user shares one channel, closed when the last listener cancels, and the stream emits the current status first. + +Any signed-in user may watch anyone's status, like profiles, which every signed-in user can read; only the user themselves may publish on their channel, so nobody can make someone else appear online, and the anon key alone can't watch anyone. Restrict `chats.is_presence_topic` (`03_database_policy.sql`) if your app needs a narrower audience. The policies match the default `realtimeOnlineUserPrefixChannel` (`online-user-`): update them if you change it. ## Self-hosted notes -* `supabase db reset` (or restoring a database) restarts Postgres but not Realtime, whose Postgres Changes poller can stay in a degraded state and silently stop delivering events: restart the Realtime container afterwards. `tool/local_db.sh` does it for the local stack. -* `chats.rooms`, `chats.room_members`, `chats.users` and `chats.messages` are added to the `supabase_realtime` publication by `01_database_schema.sql`. +* `supabase db reset` (or restoring a database) restarts Postgres but not Realtime, which can then stay in a degraded state and silently stop delivering events: restart the Realtime container afterwards. `tool/local_db.sh` does it for the local stack. +* Private channels need Realtime's authorization enabled (the default on Supabase); with "Allow public access" disabled, clients can only use the private channels above. +* `chats.users` and `chats.messages` stay in the `supabase_realtime` publication for apps that use them; the package itself doesn't subscribe to Postgres Changes. diff --git a/doc/docs/guides/supabase-security-rls.md b/doc/docs/guides/supabase-security-rls.md index b575722..4781751 100644 --- a/doc/docs/guides/supabase-security-rls.md +++ b/doc/docs/guides/supabase-security-rls.md @@ -7,10 +7,10 @@ title: Security Rules ## Access model -* **`chats.users`**: readable by any authenticated user, updatable by its owner only, created and deleted by triggers/foreign keys only. -* **`chats.rooms`**: readable by members. Created only through `chats.create_room()` (room and members in one transaction, direct rooms de-duplicated). Group rooms are renamed and deleted by their admins, direct rooms by either member. -* **`chats.room_members`**: readable by members of the same room. Group admins add and remove members and change roles; anyone can leave a room. Nobody can add themselves: knowing a room's id is not enough to join it. -* **`chats.messages`**: readable by members; a message can only be sent as yourself, edited by its author, and deleted by its author or a group admin. +* **`chats.users`**: readable by any authenticated user, updatable by its owner only (name, avatar, metadata, `lastSeen`: not the global `role`), created and deleted by triggers/foreign keys only. +* **`chats.rooms`**: readable by members. Created only through `chats.create_room()` (room and members in one transaction, direct rooms de-duplicated). Group rooms are renamed and deleted by their admins, direct rooms by either member. A room's type and message snapshot are not writable. +* **`chats.room_members`**: readable by members of the same room. Group admins add and remove members and change roles; anyone can leave a room. Nobody can add themselves: knowing a room's id is not enough to join it, and a direct room's membership is fixed (a membership row can't be moved to another room or user). +* **`chats.messages`**: readable by members; a message can only be sent as yourself, edited by its author (content only), and deleted by its author or a group admin. Ids are unique, and the time, status, reactions, pin and forward origin are set by the database. * Read receipts, reactions, pins and per-user preferences touch rows of other people (or columns clients may not write), so they go through `SECURITY DEFINER` RPCs that check membership themselves. "Admin" means a member with role `admin` in a **group** room. In a **direct** room both members have the same rights over it, so the admin helper is true for either of them. @@ -62,18 +62,19 @@ CREATE OR REPLACE FUNCTION chats.chat_member_role(room_id uuid) ... Path and topic helpers never raise on malformed input (a bare `::uuid` cast inside a policy could fail queries on unrelated rows, since SQL doesn't guarantee the evaluation order of AND-ed conditions): -* `chats.is_chat_member_path(object_name)`: is the caller a member of the room whose id is the first folder of a `chats_assets` path? Folders named after a 1.x numeric room id are resolved through `chats.room_id_migration_map`, so attachments uploaded before the migration stay readable until `migrate_storage_uuids` moves them (see MIGRATION.md). +* `chats.room_id_from_path(object_name)`: the room a `chats_assets` path belongs to (its first folder). Folders named after a 1.x numeric room id are resolved through `chats.room_id_migration_map`, so attachments uploaded before the migration stay reachable until `migrate_storage_uuids` moves them (see MIGRATION.md). +* `chats.is_chat_member_path(object_name)` / `chats.is_chat_admin_path(object_name)`: is the caller a member / a group admin of that room? * `chats.is_owner_path(object_name)`: is the first folder the caller's own user id? -* `chats.is_chat_member_topic(topic)`: is the caller a member of the room of a `chats:room:` Realtime topic? False for any other topic. +* `chats.is_chat_member_topic(topic)`: is the caller a member of the room of a `chats:room:` or `chats:room::typing` Realtime topic? `chats.is_chat_typing_topic(topic)`: the typing topic only (the one clients may send on). `chats.is_own_user_topic(topic)`: is it the caller's `chats:user:` topic? `chats.is_presence_topic(topic)` / `chats.is_own_presence_topic(topic)`: an `online-user-` topic / the caller's own. All false for any other topic. ## Tables | Table | INSERT | SELECT | UPDATE | DELETE | |-------|--------|--------|--------|--------| | `chats.users` | Nobody (sign-up trigger) | Authenticated users | Owner | Nobody (deleted with `auth.users`) | -| `chats.rooms` | Nobody (`chats.create_room`) | Members | Admins | Admins | -| `chats.room_members` | Group admins, group rooms only | Members of the same room | Admins | Yourself, or admins | -| `chats.messages` | Members, as themselves | Members | Author | Author, or group admin | +| `chats.rooms` | Nobody (`chats.create_room`) | Members | Admins (name, image, metadata) | Admins | +| `chats.room_members` | Group admins, group rooms only | Members of the same room | Group admins (role only) | Yourself, or admins | +| `chats.messages` | Members, as themselves | Members | Author (content only) | Author, or group admin | ### `chats.users` @@ -131,10 +132,14 @@ CREATE POLICY "chats.room_members_grant_read" ON chats.room_members AS PERMISSIVE FOR SELECT TO authenticated USING (chats.is_chat_member("roomId")); --- Role changes ("lastReadAt", "pinnedAt", "mutedUntil" go through RPCs). +-- Role changes by the admins of a group room ("lastReadAt", "pinnedAt", +-- "mutedUntil" go through RPCs; only "role" is writable at all). CREATE POLICY "chats.room_members_grant_update" ON chats.room_members AS PERMISSIVE FOR UPDATE TO authenticated - USING (chats.is_chat_admin("roomId")); + USING ( + chats.chat_member_role("roomId") = 'admin' + AND EXISTS (SELECT 1 FROM chats.rooms r WHERE r.id = "roomId" AND r.type = 'group') + ); -- Leave a room (your own row) or remove a member (admins). CREATE POLICY "chats.room_members_grant_delete" ON chats.room_members @@ -169,7 +174,12 @@ CREATE POLICY "chats.messages_grant_delete" ON chats.messages UPDATE policies state their rule once, in `USING`: without `WITH CHECK`, Postgres applies the `USING` expression to the new row too, so a row can neither be selected nor moved outside of the rule. -Enabling RLS on a partitioned table doesn't apply to partitions created later, so `chats.ensure_messages_partitions_rls()` enables it on every `chats.messages` partition and revokes their direct grants (partitions are only reached through the parent). It runs after the scripts and hourly with the `pg_cron` partition maintenance. +Policies decide which **rows** a user may write; which **columns** is decided by column privileges and triggers: + +* `99_database_schema_permission.sql` grants `UPDATE` on `chats.rooms` only for `name`, `imageUrl`, `metadata`, `updatedAt`; on `chats.room_members` only for `role`; on `chats.users` only for `firstName`, `lastName`, `imageUrl`, `metadata`, `updatedAt`, `lastSeen`. Without them an admin (or either member of a direct room) could move a membership to another room or user, or change a room's type, and reach states the `INSERT` rules forbid, such as a third member in a direct room. +* On `chats.messages`, writes from the API roles go through triggers (see [Database Triggers](supabse-trigges.md)): ids are unique (`chats.message_ids`), `createdAt` is set by the database and rejected when far from its clock (it is the partition key and orders everyone's timeline), status/reactions/pin start empty and only change through the RPCs, an edit can only change the content (and always sets `updatedAt`), and a forward origin is attested from the forwarded message. + +Enabling RLS on a partitioned table doesn't apply to partitions created later, so `chats.ensure_messages_partitions_rls()` enables it on every `chats.messages` partition and revokes their direct grants (partitions are only reached through the parent). An event trigger runs it as soon as a partition is created (pg_partman maintenance, `partition_data_proc()`, or by hand), and a separate hourly `pg_cron` job runs it too, as a fallback where event triggers can't be created. ## RPCs @@ -179,25 +189,29 @@ All `SECURITY DEFINER` (they bypass RLS), so each one checks the caller itself: |----------|--------|------| | `chats.create_room(p_type, p_user_ids, p_name, p_image_url, p_metadata, p_creator_role)` | Logged in, valid type, known users, exactly one other user for `direct` | Creates the room and its members (the caller always included, with `p_creator_role` in groups); returns an existing direct room for the same pair and metadata scope, serialized with an advisory lock | | `chats.mark_room_as_read(p_room_id)` | Member | Stores the caller's `lastReadAt` and flips the other members' messages to `seen` | -| `chats.toggle_message_reaction(p_message_id, p_reaction)` | Member, reaction of 1–64 characters | Adds/removes the caller's reaction atomically; returns the reactions map | +| `chats.toggle_message_reaction(p_message_id, p_reaction)` | Member, reaction of 1–64 characters | Adds/removes the caller's reaction atomically on exactly the authorized row; returns the reactions map | | `chats.pin_room(p_room_id, p_pinned)` | Member | Sets the caller's own `pinnedAt` | | `chats.mute_room(p_room_id, p_until)` | Member | Sets the caller's own `mutedUntil` (`'infinity'` for ever, `NULL` to unmute) | -| `chats.set_message_pinned(p_message_id, p_pinned)` | Admin (either member of a direct room) | Pins/unpins a message for everyone | +| `chats.set_message_pinned(p_message_id, p_pinned)` | Admin (either member of a direct room) | Pins/unpins exactly the authorized message for everyone | ## Realtime -Room channels (`chats:room:`) are private. Members may join them (receive the message changes and typing events) and send Broadcast messages to them; nobody else can: +Every channel is private. Room members receive the message changes of `chats:room:` (sent by the database only) and receive and send typing events on `chats:room::typing`; each user receives their own `chats:user:` (room and membership changes); any signed-in user may watch an `online-user-` Presence channel, only `` may publish on it: ```sql CREATE POLICY "chats.realtime_room_read" ON realtime.messages AS PERMISSIVE FOR SELECT TO authenticated - USING (chats.is_chat_member_topic((SELECT realtime.topic()))); + USING ( + chats.is_chat_member_topic((SELECT realtime.topic())) + OR chats.is_own_user_topic((SELECT realtime.topic())) + OR chats.is_presence_topic((SELECT realtime.topic())) + ); CREATE POLICY "chats.realtime_room_write" ON realtime.messages AS PERMISSIVE FOR INSERT TO authenticated WITH CHECK ( - extension = 'broadcast' - AND chats.is_chat_member_topic((SELECT realtime.topic())) + (extension = 'broadcast' AND chats.is_chat_typing_topic((SELECT realtime.topic()))) + OR (extension = 'presence' AND chats.is_own_presence_topic((SELECT realtime.topic()))) ); ``` @@ -209,7 +223,7 @@ Both buckets are private; files are served through authenticated URLs (`/storage | Bucket | INSERT | SELECT | UPDATE | DELETE | |--------|--------|--------|--------|--------| -| `chats_assets` (`/`) | Room members | Room members | Room members | Room members | +| `chats_assets` (`/`) | Room members | Room members | Uploader | Uploader, or group admin | | `chats_user_avatar` (`/`) | Owner | Authenticated users | Owner | Owner | ```sql @@ -238,7 +252,39 @@ using ( chats.is_auth()); ``` -The other `chats_assets` policies use `chats.is_chat_member_path(name)` and the other avatar policies `chats.is_owner_path(name)` in the same way. +An attachment follows the rules of the message it belongs to: only its uploader may overwrite it (an upsert by another member would change what the message shows under its author's name), and only its uploader or a group admin may delete it: + +```sql +create policy "storage.object_grant_update_auth_chats_assets" +on storage.objects for update +to authenticated +using ( + bucket_id = 'chats_assets' + and + owner_id = (select auth.uid()::text) + and + chats.is_chat_member_path(name)) +with check ( + bucket_id = 'chats_assets' + and + owner_id = (select auth.uid()::text) + and + chats.is_chat_member_path(name)); + +create policy "storage.object_grant_delete_auth_chats_assets" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'chats_assets' + and + chats.is_chat_member_path(name) + and + (owner_id = (select auth.uid()::text) or chats.is_chat_admin_path(name))); +``` + +The `chats_assets` insert policy uses `chats.is_chat_member_path(name)` and the other avatar policies `chats.is_owner_path(name)` in the same way. + +Deleting a message queues its attachment for the `cleanup-media` Edge Function, which deletes with the service role (bypassing these policies): the queue only accepts a `chats_assets` object inside the deleted message's own room folder that no other message references, and the function checks it again before deleting. ## Privileges @@ -258,7 +304,7 @@ and the indexes not used yet on a new project. ## Internal tables -* `chats.media_pending_deletion` (the media cleanup queue) has RLS enabled and no policies: only `SECURITY DEFINER` functions and the `service_role` (the `cleanup-media` Edge Function) reach it. +* `chats.media_pending_deletion` (the media cleanup queue) and `chats.message_ids` (the message id registry) have RLS enabled and no policies: only `SECURITY DEFINER` functions and the `service_role` (the `cleanup-media` Edge Function) reach them. * `chats.room_id_migration_map` (1.x → 2.x room ids, only present after a migration, dropped by `migrate_storage_uuids` once the attachments are moved) is internal bookkeeping read by `chats.is_chat_member_path`. * After a 1.x migration, the `*_backup_` tables keep RLS enabled with their policies dropped, so they are closed to clients. diff --git a/doc/docs/guides/supabase-usage.md b/doc/docs/guides/supabase-usage.md index 12d617a..8148eb4 100644 --- a/doc/docs/guides/supabase-usage.md +++ b/doc/docs/guides/supabase-usage.md @@ -211,11 +211,11 @@ await _controller.sendMessage( ``` * The message is inserted locally right away with status `sending`. -* `authorId` is always set to the logged-in user (RLS enforces it too), `createdAt` defaults to now, and the database sets the status to `sent`. -* The id is stored as-is, so the Realtime echo of the insert updates the local copy instead of duplicating it. Use `const Uuid().v7()`: v7 ids are time-ordered like the database's own `chats.uuid_generate_v7()`, which keeps the primary key index insert-friendly. -* On failure the local copy keeps status `error` and the exception is rethrown; `retryMessage(message)` sends it again, `deleteMessage(id)` drops it locally. +* `authorId` is always set to the logged-in user (RLS enforces it too). `createdAt` is assigned by the database (the local copy keeps the device time until the Realtime echo replaces it): a device clock can't decide where a message lands in everyone's timeline. The database also sets the status to `sent`. +* The id is stored as-is, so the Realtime echo of the insert updates the local copy instead of duplicating it. Use `const Uuid().v7()`: v7 ids are time-ordered like the database's own `chats.uuid_generate_v7()`, which keeps the primary key index insert-friendly. Ids are unique: an id already used by another message is rejected. +* On failure the local copy keeps status `error` and the exception is rethrown; `retryMessage(message)` sends it again (a retry of a send that did reach the server is recognized and marked sent), `deleteMessage(id)` drops it locally. -`sendMessage` also stops the typing indicator. Upload attachments first with `SupabaseChatCore.instance.uploadAsset(room, fileName, bytes)`: it returns an `UploadAssetResult` with the authenticated `url`, the detected `mimeType` and the object `path` (`/-`). Load those URLs with `SupabaseChatCore.instance.httpSupabaseHeaders` (e.g. `FlyerChatImageMessage(headers: ...)`). +`sendMessage` also stops the typing indicator. Upload attachments first with `SupabaseChatCore.instance.uploadAsset(room, fileName, bytes)`: it returns an `UploadAssetResult` with the authenticated `url`, the detected `mimeType` and the object `path` (`/-`). Load those URLs with `SupabaseChatCore.instance.httpSupabaseHeadersFor(url)` (e.g. `FlyerChatImageMessage(headers: chatCore.httpSupabaseHeadersFor(message.source))`): it returns the access token only for this project's Storage URLs. Avatars, room images and message sources are written by users and can point at any server, so never attach the token to them unconditionally (the deprecated `httpSupabaseHeaders` does). `SupabaseChatCore.instance.sendMessage(message, roomId)` sends without the optimistic part, e.g. from code that doesn't use a controller. @@ -249,9 +249,10 @@ With `autoMarkAsRead` the controller calls `markRoomAsRead` once the first page await SupabaseChatCore.instance.forwardMessages(room, selectedMessages, targetRoomId); ``` -* The copies are sent oldest first, a millisecond apart, so they keep their order. +* The copies are sent oldest first in one insert, and keep their order (the database assigns their time). * Each copy keeps the content and the metadata, without the reply, the reactions, the pin and the edited mark. * Its metadata stores a `ChatForwardOrigin` under `forwardedFrom`: the original author's id and name and the original date. Read it with `ChatForwardOrigin.of(message)`, which returns `null` for messages that weren't forwarded, e.g. to show a "Forwarded from ..." header. Forwarding a forwarded message keeps the first origin. +* The origin is attested by the database: the client only names the forwarded message (`ChatForwardOrigin.claim`), and the `chats.attest_forward_origin` trigger fills in its real author and date after checking the forwarder can read it. Any other `forwardedFrom` value is rejected, so an origin can't be made up. * The author's name is a snapshot taken when forwarding: the recipients may not share a room with the original author, so they can't read their profile. * Attachments stored in the chat assets bucket are copied into `toRoomId`'s folder, since the Storage policies only let members of a room read its files; other URLs are kept as they are. * `TextStreamMessage`s and `UnsupportedMessage`s can't be stored and are skipped. @@ -324,7 +325,7 @@ Hooks are evaluated on every call, so they can read state that changes at runtim Hooks only narrow what the client asks for. If a scope is a security boundary, enforce the same rule server side with RLS policies. ::: -When you rename tables, views, buckets or `realtimeRoomChannelPrefix`, update the SQL scripts accordingly: the channel prefix is also hard-coded in the `chats.broadcast_message_change` trigger and in `chats.is_chat_member_topic`. +When you rename tables, views, buckets or the Realtime channel prefixes, update the SQL scripts accordingly: the prefixes are also hard-coded in the triggers (`chats.broadcast_message_change`, `chats.send_room_change`), in the Realtime topic helpers (`chats.room_id_from_topic`, `chats.is_own_user_topic`, `chats.is_presence_topic`, `chats.is_own_presence_topic`), and the chat assets bucket in `chats.queue_media_for_deletion` and the `cleanup-media` function (`CHAT_ASSETS_BUCKET`). To work with a different `SupabaseClient` (e.g. several logged-in users in one process, as the integration tests do), create your own `SupabaseChatCore(client: ..., config: ...)`, pass it as `chatCore:` to controllers and widgets, and `dispose()` it when done. diff --git a/doc/docs/guides/supabse-trigges.md b/doc/docs/guides/supabse-trigges.md index 26f5009..e766508 100644 --- a/doc/docs/guides/supabse-trigges.md +++ b/doc/docs/guides/supabse-trigges.md @@ -98,27 +98,29 @@ END; $$ LANGUAGE plpgsql; ``` -The room update is what the rooms list listens to over Realtime (Postgres Changes on `chats.rooms`). +The room update is what the rooms list listens to over Realtime (see [Room and membership changes](#room-and-membership-changes)). ## Set message status to sent -```sql -CREATE OR REPLACE FUNCTION chats.set_message_status_to_sent() - RETURNS TRIGGER - SET search_path = '' -AS $$ -BEGIN - NEW.status := 'sent'; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; -``` +A new message's server-owned columns, for writes from the API roles (`anon`, `authenticated`; the `SECURITY DEFINER` RPCs and maintenance run as the table owner and are not affected): the status is `sent`, pin and reactions start empty, and `createdAt` is the database's (its default, `clock_timestamp()`). A client value of `createdAt` is only accepted within a few minutes of the server clock: it is the partition key and orders every member's timeline, so a far-future date would stay the room's last message for ever and block pg_partman's maintenance. It is rejected rather than clamped, because a `BEFORE` trigger can't move a row to another partition. Later statuses come from `chats.mark_room_as_read()`, which flips messages to `seen`. +## Protect message columns + +`BEFORE UPDATE`, API roles only: an author (the only one RLS lets update a message) may change its content, nothing else. The id, room, author, `createdAt`, status, reactions, pin and forward origin keep their stored values, and `updatedAt` is set by the database whenever the content changes, so an edit is always visible. + +## Message ids + +`chats.claim_message_id()` registers every new message id in `chats.message_ids` and releases it when the message is deleted. Ids are supplied by clients (optimistic UI), and the partitioned primary key `(id, "createdAt")` can't make `id` alone unique: the registry does, so a reused id fails with a unique violation (`23505`) instead of creating a second row that the RPCs, deletes and clients addressing messages by id would confuse with the first. + +## Attest forward origin + +`BEFORE INSERT`, API roles only. A forwarded message's `metadata.forwardedFrom` must name the source (`{"sourceRoomId", "sourceMessageId"}`, see `ChatForwardOrigin.claim`): the trigger reads it with the forwarder's privileges (so it must be a message they can read) and replaces the claim with the source's author, author name and date, or keeps the source's own attested origin when it is itself a forward. Any other value is rejected. + ## Queue media for deletion -SQL can't delete objects from the storage backend. When a deleted message's `source` points at Supabase Storage (`.../object/authenticated//`), the bucket and path are recorded in `chats.media_pending_deletion`, for the `cleanup-media` Edge Function to remove later (see [Usage](supabase-usage.md#media-cleanup)). The message row itself is deleted immediately. `SECURITY DEFINER` because the queue is closed to clients. +SQL can't delete objects from the storage backend. When a deleted message's `source` points at one of its own attachments (`.../object/authenticated/chats_assets//`, the message's own room folder, or the room's 1.x numeric folder), the bucket and path are recorded in `chats.media_pending_deletion`, for the `cleanup-media` Edge Function to remove later (see [Usage](supabase-usage.md#media-cleanup)), unless another message still references the same file. `source` is written by the author, and the function deletes with the service role, which bypasses storage RLS: any other URL (another room, another bucket, an external server) is never queued. The message row itself is deleted immediately. `SECURITY DEFINER` because the queue is closed to clients. ## Delete room if empty @@ -163,7 +165,11 @@ END; $$; ``` -A failure to broadcast never loses the message: it is logged as a `WARNING` and the write goes through. If you change `SupabaseChatCoreConfig.realtimeRoomChannelPrefix`, change the `'chats:room:'` prefix here and in `chats.is_chat_member_topic()` too. +A failure to broadcast never loses the message: it is logged as a `WARNING` and the write goes through. If you change `SupabaseChatCoreConfig.realtimeRoomChannelPrefix`, change the `'chats:room:'` prefix here and in `chats.room_id_from_topic()` too. + +## Room and membership changes + +Statement-level triggers on `chats.rooms` (`UPDATE`) and `chats.room_members` (`INSERT`, `UPDATE`, `DELETE`), using transition tables, call `chats.send_room_change(room, user, removed)` once per affected member: it sends a `room_change` event (`{roomId, removed}`) on the member's private channel `chats:user:` with `realtime.send`. Every member hears about room updates, joins, leaves and role changes; a member's own `lastReadAt`/`pinnedAt`/`mutedUntil` changes only reach that member; the user who left or was removed (or whose room was deleted) gets `removed: true`. `SupabaseChatCore.roomsChanges()` and `room(id)` listen to it, and the package closes the room's channels on removal (see [Realtime](supabase-realtime.md#rooms-list)). Failures are reported as `WARNING`s, like the message broadcast. If you change `SupabaseChatCoreConfig.realtimeUserChannelPrefix`, change the `'chats:user:'` prefix here and in `chats.is_own_user_topic()` too. ## Ideas diff --git a/doc/docs/introduction/supabase-overview.md b/doc/docs/introduction/supabase-overview.md index 03e34db..abcdd46 100644 --- a/doc/docs/introduction/supabase-overview.md +++ b/doc/docs/introduction/supabase-overview.md @@ -31,7 +31,7 @@ Two views, `chats.rooms_l` and `chats.messages_l`, are what the client reads: `r Writes that touch other people's rows (creating a room with its members, read receipts, reactions, pins) go through `SECURITY DEFINER` functions that check membership themselves. See [Security Rules](../guides/supabase-security-rls.md). -[Supabase Realtime](https://supabase.com/docs/guides/realtime) keeps the UI live: message changes travel on a private Broadcast channel per room, sent by a database trigger; the rooms list follows Postgres Changes; online status uses Presence. See [Realtime](../guides/supabase-realtime.md). +[Supabase Realtime](https://supabase.com/docs/guides/realtime) keeps the UI live: message changes travel on a private Broadcast channel per room, sent by a database trigger; room and membership changes on a private channel per user, also sent by the database; online status uses private Presence channels. See [Realtime](../guides/supabase-realtime.md). [Supabase Storage](https://supabase.com/docs/guides/storage) holds attachments (`chats_assets`, one folder per room) and avatars (`chats_user_avatar`, one folder per user), both private buckets. diff --git a/doc/docs/introduction/supabase-package-installation.md b/doc/docs/introduction/supabase-package-installation.md index 7f2afcf..de9652d 100644 --- a/doc/docs/introduction/supabase-package-installation.md +++ b/doc/docs/introduction/supabase-package-installation.md @@ -5,11 +5,11 @@ title: Package installation This package depends on [supabase_flutter](https://pub.dev/packages/supabase_flutter): follow its [instructions](https://supabase.com/docs/reference/dart/introduction) to add Supabase to your app. -Requirements: `Dart >=3.8.0` and `Flutter >=3.32.0`. +Requirements: `Dart >=3.9.0` and `Flutter >=3.35.0`. ```yaml dependencies: - flutter_supabase_chat_core: ^2.0.0-rc.2 + flutter_supabase_chat_core: ^2.0.0-rc.3 supabase_flutter: ^2.17.2 uuid: ^4.5.1 diff --git a/doc/docusaurus.config.ts b/doc/docusaurus.config.ts index ab0fa38..bae0ece 100644 --- a/doc/docusaurus.config.ts +++ b/doc/docusaurus.config.ts @@ -7,16 +7,11 @@ const config: Config = { tagline: 'Real-time chat for Flutter on Supabase', favicon: 'img/favicon.ico', - // Set the production url of your site here url: 'https://flutter-supabase-chat-core.insideapp.it', - // Set the // pathname under which your site is served - // For GitHub pages deployment, it is often '//' baseUrl: '/', - // GitHub pages deployment config. - // If you aren't using GitHub pages, you don't need these. - organizationName: 'insideapp-srl', // Usually your GitHub org/user name. - projectName: 'flutter_supabase_chat_core', // Usually your repo name. + organizationName: 'insideapp-srl', + projectName: 'flutter_supabase_chat_core', onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', @@ -32,9 +27,6 @@ const config: Config = { }, ], - // Even if you don't use internationalization, you can use this field to set - // useful metadata like html lang. For example, if your site is Chinese, you - // may want to replace "en" with "zh-Hans". i18n: { defaultLocale: 'en', locales: ['en'], @@ -76,9 +68,9 @@ const config: Config = { items: [ { type: 'docSidebar', - sidebarId: 'tutorialSidebar', + sidebarId: 'docsSidebar', position: 'left', - label: 'Tutorial', + label: 'Docs', }, {to: 'https://flyer.chat/docs/flutter/introduction/', label: 'Chat UI', position: 'left'}, { @@ -95,7 +87,7 @@ const config: Config = { title: 'Docs', items: [ { - label: 'How use', + label: 'Getting started', to: '/', }, ], @@ -104,7 +96,7 @@ const config: Config = { title: 'Community', items: [ { - label: 'Site', + label: 'InsideApp', href: 'https://www.insideapp.it/', }, { @@ -114,7 +106,7 @@ const config: Config = { ], }, ], - copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`, + copyright: `Copyright © ${new Date().getFullYear()} InsideApp S.r.l. Built with Docusaurus.`, }, prism: { theme: prismThemes.github, diff --git a/doc/package.json b/doc/package.json index 292586f..7098fbf 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "flutter-supabase-chat-core", - "version": "1.4.3", + "version": "2.0.0-rc.3", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/doc/sidebars.ts b/doc/sidebars.ts index 70a55d7..adc6bd6 100644 --- a/doc/sidebars.ts +++ b/doc/sidebars.ts @@ -1,22 +1,7 @@ import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; -/** - * Creating a sidebar enables you to: - - create an ordered group of docs - - render a sidebar for each doc of that group - - provide next/previous navigation - - The sidebars can be generated from the filesystem, or explicitly defined here. - - Create as many sidebars as you want. - */ const sidebars: SidebarsConfig = { - // By default, Docusaurus generates a sidebar from the docs folder structure - //tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], - - // But you can create a sidebar manually - - tutorialSidebar: [ + docsSidebar: [ { type: 'category', label: 'Introduction', diff --git a/example/lib/src/pages/image_viewer_page.dart b/example/lib/src/pages/image_viewer_page.dart index 879d2d7..2e4e6c9 100644 --- a/example/lib/src/pages/image_viewer_page.dart +++ b/example/lib/src/pages/image_viewer_page.dart @@ -22,7 +22,9 @@ class ImageViewerPage extends StatelessWidget { tag: message.id, child: Image.network( message.source, - headers: SupabaseChatCore.instance.httpSupabaseHeaders, + headers: SupabaseChatCore.instance.httpSupabaseHeadersFor( + message.source, + ), loadingBuilder: (context, child, progress) => progress == null ? child : const CircularProgressIndicator(color: Colors.white), diff --git a/example/lib/src/pages/room_page.dart b/example/lib/src/pages/room_page.dart index c546619..ffb7f8f 100644 --- a/example/lib/src/pages/room_page.dart +++ b/example/lib/src/pages/room_page.dart @@ -215,7 +215,7 @@ class _RoomPageState extends State { ), ); case core.FileMessage(): - await _guard(() => openFileMessage(message)); + await _guard(() => openFileMessage(context, message)); default: break; } @@ -571,7 +571,6 @@ class _RoomPageState extends State { final scheme = theme.colorScheme; final palette = ChatPalette.of(context); final chatTheme = AppTheme.chatTheme(theme); - final headers = _chat.httpSupabaseHeaders; TextStyle metaStyle(bool mine) => theme.textTheme.labelSmall!.copyWith( color: mine ? palette.sentMeta : palette.receivedMeta, ); @@ -777,7 +776,7 @@ class _RoomPageState extends State { child: FlyerChatImageMessage( message: message, index: index, - headers: headers, + headers: _chat.httpSupabaseHeadersFor(message.source), showStatus: isSentByMe, borderRadius: _bubbleRadius(isSentByMe, groupStatus), timeBackground: Colors.black45, diff --git a/example/lib/src/utils/attachments.dart b/example/lib/src/utils/attachments.dart index 1319705..74ccefd 100644 --- a/example/lib/src/utils/attachments.dart +++ b/example/lib/src/utils/attachments.dart @@ -198,22 +198,88 @@ String voiceMimeType(String source) { return 'audio/mp4'; } -/// Downloads a file message (with the auth headers) and opens it. -Future openFileMessage(core.FileMessage message) async { +/// Extensions [openFileMessage] hands to the system viewer after saving: +/// documents and media only. Anything else (scripts, executables, archives, +/// office files with macros, ...) is saved but not opened: a file message is +/// sent by another member, who picks its name and content. +const _openableExtensions = { + 'pdf', + 'txt', + 'csv', + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'heic', + 'mp3', + 'm4a', + 'aac', + 'wav', + 'ogg', + 'mp4', + 'mov', + 'webm', +}; + +/// A file name safe to save locally: the last path segment of [name] with +/// any character other than letters, digits, `.`, `_` and `-` replaced, so +/// a sender can't make the download land outside the downloads folder. +String safeDownloadName(String name) { + final base = name.split(RegExp(r'[/\\]')).last.trim(); + final safe = base.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + return safe.isEmpty || RegExp(r'^\.+$').hasMatch(safe) ? 'download' : safe; +} + +/// Downloads a file message (with the auth headers when it is stored in this +/// project's Storage), saves it and, for documents and media, opens it once +/// the user confirms. +Future openFileMessage( + BuildContext context, + core.FileMessage message, +) async { final response = await http.get( Uri.parse(message.source), - headers: SupabaseChatCore.instance.httpSupabaseHeaders, + headers: SupabaseChatCore.instance.httpSupabaseHeadersFor(message.source), ); if (response.statusCode != 200) { throw SupabaseChatCoreStorageException( 'Download failed (${response.statusCode})', ); } + final name = safeDownloadName(message.name); final saved = await FileSaver.instance.saveFile( - name: message.name, + name: name, bytes: response.bodyBytes, ); - if (!kIsWeb) await OpenFilex.open(saved); + if (kIsWeb || !context.mounted) return; + final extension = name.contains('.') + ? name.split('.').last.toLowerCase() + : ''; + if (!_openableExtensions.contains(extension)) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Saved to $saved'))); + return; + } + final open = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Open file?'), + content: Text('"$name" was saved to your device. Open it now?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Open'), + ), + ], + ), + ); + if (open ?? false) await OpenFilex.open(saved); } /// Reduces [samples] to [bars] values (max of each bucket), the resolution diff --git a/example/lib/src/widgets/audio_message_bubble.dart b/example/lib/src/widgets/audio_message_bubble.dart index 6c91728..17e4b73 100644 --- a/example/lib/src/widgets/audio_message_bubble.dart +++ b/example/lib/src/widgets/audio_message_bubble.dart @@ -92,7 +92,9 @@ class _AudioMessageBubbleState extends State { // attach on every platform: fetch the (authenticated) bytes first. final response = await http.get( Uri.parse(widget.message.source), - headers: SupabaseChatCore.instance.httpSupabaseHeaders, + headers: SupabaseChatCore.instance.httpSupabaseHeadersFor( + widget.message.source, + ), ); if (response.statusCode != 200) { throw SupabaseChatCoreStorageException( diff --git a/example/lib/src/widgets/user_avatar.dart b/example/lib/src/widgets/user_avatar.dart index 5221040..2695846 100644 --- a/example/lib/src/widgets/user_avatar.dart +++ b/example/lib/src/widgets/user_avatar.dart @@ -110,7 +110,11 @@ class UserAvatar extends StatelessWidget { ? fallback : Image.network( imageUrl!, - headers: SupabaseChatCore.instance.httpSupabaseHeaders, + // Avatars are URLs chosen by users: the session is only sent + // to this project's Storage. + headers: SupabaseChatCore.instance.httpSupabaseHeadersFor( + imageUrl!, + ), fit: BoxFit.cover, width: size, height: size, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6de861d..f35d72f 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -8,7 +8,7 @@ publish_to: 'none' version: 2.0.0 environment: - sdk: '>=3.8.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' dependencies: audioplayers: ^6.8.1 diff --git a/example/utils/sql/01_database_schema.sql b/example/utils/sql/01_database_schema.sql index c7e48fe..d3bbbee 100644 --- a/example/utils/sql/01_database_schema.sql +++ b/example/utils/sql/01_database_schema.sql @@ -209,7 +209,6 @@ BEGIN ALTER TABLE chats.rooms OWNER TO postgres; ALTER TABLE ONLY chats.rooms ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); ALTER TABLE chats.rooms ENABLE ROW LEVEL SECURITY; - ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.rooms; ELSIF v_is_v1 THEN RAISE NOTICE 'v1 schema detected (chats.rooms.id is bigint): migrating users and rooms to v2 shape'; @@ -313,7 +312,6 @@ BEGIN ) THEN EXECUTE format('ALTER PUBLICATION supabase_realtime DROP TABLE chats.%I', v_rooms_backup); END IF; - ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.rooms; RAISE NOTICE 'chats.% kept as a backup — verify row counts match, then drop it yourself once satisfied.', v_rooms_backup; ELSE @@ -343,7 +341,6 @@ BEGIN ); ALTER TABLE chats.room_members OWNER TO postgres; ALTER TABLE chats.room_members ENABLE ROW LEVEL SECURITY; - ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.room_members; IF v_is_v1 THEN RAISE NOTICE 'Populating chats.room_members from chats.%''s userIds/userRoles', v_rooms_backup; @@ -397,7 +394,7 @@ BEGIN -- replyToMessageId, ...). CREATE TABLE chats.messages ( id uuid NOT NULL DEFAULT chats.uuid_generate_v7(), - "createdAt" timestamptz NOT NULL DEFAULT now(), + "createdAt" timestamptz NOT NULL DEFAULT clock_timestamp(), metadata jsonb, duration bigint, "mimeType" text, @@ -706,26 +703,121 @@ BEGIN END $do$; +-- Retry bookkeeping of the cleanup queue: cleanup-media retries a failing +-- object a few times, then leaves it aside (with the last error) instead of +-- letting it block every object queued after it. +ALTER TABLE chats.media_pending_deletion ADD COLUMN IF NOT EXISTS attempts int NOT NULL DEFAULT 0; +ALTER TABLE chats.media_pending_deletion ADD COLUMN IF NOT EXISTS "lastError" text; + +-- "createdAt" is the partition key and the time every member sees: the +-- server assigns it (02_database_trigger.sql rejects client values far from +-- it). clock_timestamp() rather than now(): the rows of a multi-row insert +-- (e.g. forwarded messages) keep their order. +ALTER TABLE chats.messages ALTER COLUMN "createdAt" SET DEFAULT clock_timestamp(); + +-- Message ids are supplied by clients (optimistic UI), and the partitioned +-- primary key is (id, "createdAt"), so it can't make id alone unique. This +-- registry does: chats.claim_message_id() (02_database_trigger.sql) inserts +-- every new id here, and a reused one fails with a unique violation. Without +-- it, a member could create a second row with another message's id and get +-- the RPCs/deletes that address messages by id to act on both. +CREATE TABLE IF NOT EXISTS chats.message_ids ( + id uuid PRIMARY KEY +); +ALTER TABLE chats.message_ids OWNER TO postgres; +-- No policies: only the SECURITY DEFINER trigger functions touch it. +ALTER TABLE chats.message_ids ENABLE ROW LEVEL SECURITY; +INSERT INTO chats.message_ids (id) +SELECT DISTINCT id FROM chats.messages +ON CONFLICT (id) DO NOTHING; + -- Weekly-partition maintenance (pre-creating upcoming partitions). Runs -- hourly via pg_cron; cheap/no-op when there's nothing due. No retention job -- is scheduled — see the note above about keeping chat history forever. +-- +-- Two separate jobs on purpose: pg_cron runs a job's command as a single +-- transaction block, where the COMMITs of partman.run_maintenance_proc() +-- fail ("invalid transaction termination") as soon as anything else shares +-- the command. DO $do$ BEGIN IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'partman-run-maintenance') THEN PERFORM cron.schedule( 'partman-run-maintenance', '0 * * * *', - $sql$CALL partman.run_maintenance_proc(); SELECT chats.ensure_messages_partitions_rls();$sql$ + 'CALL partman.run_maintenance_proc()' ); ELSE PERFORM cron.alter_job( (SELECT jobid FROM cron.job WHERE jobname = 'partman-run-maintenance'), - command => 'CALL partman.run_maintenance_proc(); SELECT chats.ensure_messages_partitions_rls();' + command => 'CALL partman.run_maintenance_proc()' + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'chats-messages-partitions-rls') THEN + PERFORM cron.schedule( + 'chats-messages-partitions-rls', + '5 * * * *', + 'SELECT chats.ensure_messages_partitions_rls()' ); END IF; END $do$; +-- Closes every new chats.messages partition the moment it is created +-- (pg_partman maintenance, partition_data_proc(), or by hand), instead of +-- leaving it open to the API roles until the next hourly job above. +CREATE OR REPLACE FUNCTION chats.secure_new_messages_partitions() +RETURNS event_trigger +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + -- chats.messages may not exist yet (e.g. mid-migration, while this file + -- recreates the tables). + IF to_regclass('chats.messages') IS NOT NULL AND EXISTS ( + SELECT 1 FROM pg_event_trigger_ddl_commands() cmd + WHERE cmd.object_type = 'table' AND cmd.schema_name = 'chats' + ) THEN + PERFORM chats.ensure_messages_partitions_rls(); + END IF; +END; +$$; + +-- Creating event triggers needs elevated privileges: allowed for postgres +-- on Supabase, but not on every Postgres. Where it is refused, the hourly +-- chats-messages-partitions-rls job above is the fallback. +DO $do$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname = 'chats_secure_new_messages_partitions') THEN + CREATE EVENT TRIGGER chats_secure_new_messages_partitions + ON ddl_command_end + WHEN TAG IN ('CREATE TABLE', 'ALTER TABLE') + EXECUTE FUNCTION chats.secure_new_messages_partitions(); + END IF; +EXCEPTION WHEN insufficient_privilege THEN + RAISE WARNING 'chats: cannot create the event trigger securing new messages partitions (%); relying on the hourly chats-messages-partitions-rls job', SQLERRM; +END +$do$; + +-- Room and membership changes reach clients over private Broadcast +-- channels (`chats:user:`, see chats.broadcast_room_change in +-- 02_database_trigger.sql), not Postgres Changes: Realtime doesn't apply RLS +-- to DELETE events, so publishing these tables told every subscriber which +-- user left which room. Take them out of the publication on existing +-- installs too. +DO $do$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND schemaname = 'chats' AND tablename = 'rooms') THEN + ALTER PUBLICATION supabase_realtime DROP TABLE chats.rooms; + END IF; + IF EXISTS (SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND schemaname = 'chats' AND tablename = 'room_members') THEN + ALTER PUBLICATION supabase_realtime DROP TABLE chats.room_members; + END IF; +END +$do$; + REVOKE USAGE ON SCHEMA chats FROM PUBLIC; GRANT USAGE ON SCHEMA chats TO anon; GRANT USAGE ON SCHEMA chats TO authenticated; @@ -773,8 +865,10 @@ $do$; -- chats.media_pending_deletion is an internal cleanup queue for the -- cleanup-media Edge Function (service_role only) — not part of the app's --- data model, so undo the blanket anon/authenticated grant for it too. +-- data model, so undo the blanket anon/authenticated grant for it too. Same +-- for the message id registry, written by triggers only. REVOKE ALL ON TABLE chats.media_pending_deletion FROM anon, authenticated; +REVOKE ALL ON TABLE chats.message_ids FROM anon, authenticated; -- The blanket grants above also reach the chats.messages partitions, which -- must only be accessed through the parent table: take them back. diff --git a/example/utils/sql/02_database_trigger.sql b/example/utils/sql/02_database_trigger.sql index 087a8cf..4650016 100644 --- a/example/utils/sql/02_database_trigger.sql +++ b/example/utils/sql/02_database_trigger.sql @@ -89,12 +89,34 @@ CREATE TRIGGER update_last_messages_trigger FOR EACH ROW EXECUTE FUNCTION chats.update_last_messages(); +-- Server-owned columns of a new message. For writes coming from the API +-- (anon/authenticated) only -- the SECURITY DEFINER RPCs and maintenance +-- run as the table owner and are not affected: +-- - status starts as 'sent' (read receipts go through mark_room_as_read); +-- - pin and reactions start empty (set_message_pinned and +-- toggle_message_reaction own them); +-- - "createdAt" is assigned by the database (its default), and a value +-- sent by the client is only accepted close to the server's clock. It is +-- the partition key and orders every member's timeline: a far-future +-- date would stay the room's last message for ever, sit in the default +-- partition and block pg_partman's maintenance. Rejected rather than +-- clamped: a BEFORE trigger can't move a row to another partition. CREATE OR REPLACE FUNCTION chats.set_message_status_to_sent() RETURNS TRIGGER SET search_path = '' AS $$ BEGIN NEW.status := 'sent'; + IF current_user IN ('anon', 'authenticated') THEN + NEW.pinned := NULL; + NEW.reactions := NULL; + NEW."updatedAt" := NULL; + IF NEW."createdAt" > now() + interval '2 minutes' + OR NEW."createdAt" < now() - interval '10 minutes' THEN + RAISE EXCEPTION 'message createdAt must be close to the server time (omit it to let the server set it)' + USING ERRCODE = '22008'; + END IF; + END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -104,12 +126,163 @@ CREATE TRIGGER update_status_before_insert BEFORE INSERT ON chats.messages FOR EACH ROW EXECUTE FUNCTION chats.set_message_status_to_sent(); +-- What an author may change on their own message (UPDATE is author-only +-- under RLS): its content. Identity and placement (id, room, author, +-- "createdAt"), the server-owned state (status, reactions, pin) and the +-- forward origin keep their stored values, and "updatedAt" is set by the +-- server whenever the content changes, so an edit is always visible. +-- API roles only, like chats.set_message_status_to_sent(). +CREATE OR REPLACE FUNCTION chats.protect_message_columns() + RETURNS TRIGGER + SET search_path = '' +AS $$ +BEGIN + IF current_user NOT IN ('anon', 'authenticated') THEN + RETURN NEW; + END IF; + NEW.id := OLD.id; + NEW."roomId" := OLD."roomId"; + NEW."authorId" := OLD."authorId"; + NEW."createdAt" := OLD."createdAt"; + NEW.status := OLD.status; + NEW.reactions := OLD.reactions; + NEW.pinned := OLD.pinned; + NEW.metadata := coalesce(NEW.metadata, '{}'::jsonb) - 'forwardedFrom' + || CASE WHEN OLD.metadata ? 'forwardedFrom' + THEN jsonb_build_object('forwardedFrom', OLD.metadata -> 'forwardedFrom') + ELSE '{}'::jsonb END; + IF NEW.metadata = '{}'::jsonb AND OLD.metadata IS NULL THEN + NEW.metadata := NULL; + END IF; + IF (NEW.type, NEW.text, NEW.source, NEW.name, NEW."mimeType", NEW.size, NEW.duration, + NEW.waveform, NEW.height, NEW.width, NEW."linkPreviewData", NEW."replyToMessageId", NEW.metadata) + IS DISTINCT FROM + (OLD.type, OLD.text, OLD.source, OLD.name, OLD."mimeType", OLD.size, OLD.duration, + OLD.waveform, OLD.height, OLD.width, OLD."linkPreviewData", OLD."replyToMessageId", OLD.metadata) THEN + NEW."updatedAt" := now(); + ELSE + NEW."updatedAt" := OLD."updatedAt"; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +drop trigger if exists protect_message_columns_trigger on chats.messages; +CREATE TRIGGER protect_message_columns_trigger + BEFORE UPDATE ON chats.messages + FOR EACH ROW EXECUTE FUNCTION chats.protect_message_columns(); + +-- The origin of a forwarded message (metadata.forwardedFrom) is attested by +-- the database, never taken from the client: the client names the message +-- it forwards ({"sourceRoomId", "sourceMessageId"}), and this trigger +-- replaces that with the source's author, author name and date -- or keeps +-- the source's own attested origin when it is itself a forward. The source +-- is read with the caller's privileges (SECURITY INVOKER), so it must be a +-- message the forwarder can read. Any other forwardedFrom value is refused. +CREATE OR REPLACE FUNCTION chats.attest_forward_origin() + RETURNS TRIGGER + SET search_path = '' +AS $$ +DECLARE + v_claim jsonb; + v_source record; + v_name text; +BEGIN + IF current_user NOT IN ('anon', 'authenticated') + OR NOT coalesce(NEW.metadata ? 'forwardedFrom', false) THEN + RETURN NEW; + END IF; + v_claim := NEW.metadata -> 'forwardedFrom'; + IF jsonb_typeof(v_claim) <> 'object' + OR coalesce(v_claim ->> 'sourceRoomId', '') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + OR coalesce(v_claim ->> 'sourceMessageId', '') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN + RAISE EXCEPTION 'forwardedFrom must name the forwarded message (sourceRoomId, sourceMessageId)' + USING ERRCODE = '22023'; + END IF; + + SELECT m."authorId", m."createdAt", m.metadata + INTO v_source + FROM chats.messages m + WHERE m."roomId" = (v_claim ->> 'sourceRoomId')::uuid + AND m.id = (v_claim ->> 'sourceMessageId')::uuid; + IF NOT FOUND THEN + RAISE EXCEPTION 'forwarded message not found' USING ERRCODE = 'P0002'; + END IF; + + IF jsonb_typeof(v_source.metadata -> 'forwardedFrom') = 'object' + AND (v_source.metadata -> 'forwardedFrom' ->> 'attested')::boolean IS TRUE THEN + NEW.metadata := jsonb_set(NEW.metadata, '{forwardedFrom}', v_source.metadata -> 'forwardedFrom'); + ELSE + SELECT nullif(trim(concat_ws(' ', u."firstName", u."lastName")), '') + INTO v_name + FROM chats.users u WHERE u.id = v_source."authorId"; + NEW.metadata := jsonb_set(NEW.metadata, '{forwardedFrom}', jsonb_strip_nulls(jsonb_build_object( + 'authorId', v_source."authorId", + 'authorName', v_name, + 'createdAt', v_source."createdAt", + 'attested', true + ))); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +drop trigger if exists attest_forward_origin_trigger on chats.messages; +CREATE TRIGGER attest_forward_origin_trigger + BEFORE INSERT ON chats.messages + FOR EACH ROW EXECUTE FUNCTION chats.attest_forward_origin(); + +-- Registers every message id in chats.message_ids (01_database_schema.sql), +-- making ids unique across the partitioned table: reusing another message's +-- id fails with a unique violation (23505) instead of creating a second row +-- that RPCs, deletes and clients addressing messages by id would confuse +-- with the first. Releases the id when the message is deleted. +CREATE OR REPLACE FUNCTION chats.claim_message_id() + RETURNS TRIGGER + SECURITY DEFINER + SET search_path = '' +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM chats.message_ids WHERE id = OLD.id; + RETURN OLD; + END IF; + -- API roles can't change an id (chats.protect_message_columns() keeps + -- the stored one, and runs after this trigger): nothing to re-register. + IF TG_OP = 'UPDATE' AND (NEW.id = OLD.id OR current_user IN ('anon', 'authenticated')) THEN + RETURN NEW; + END IF; + INSERT INTO chats.message_ids (id) VALUES (NEW.id); + IF TG_OP = 'UPDATE' THEN + DELETE FROM chats.message_ids WHERE id = OLD.id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +drop trigger if exists claim_message_id_trigger on chats.messages; +CREATE TRIGGER claim_message_id_trigger + BEFORE INSERT OR UPDATE OF id ON chats.messages + FOR EACH ROW EXECUTE FUNCTION chats.claim_message_id(); + +drop trigger if exists release_message_id_trigger on chats.messages; +CREATE TRIGGER release_message_id_trigger + AFTER DELETE ON chats.messages + FOR EACH ROW EXECUTE FUNCTION chats.claim_message_id(); + -- A message's storage media (its `source`, when it points at the -- chats_assets bucket) can't be deleted by SQL alone -- the storage backend -- needs a real API call. Instead, record the bucket/path here for the -- cleanup-media Edge Function (see supabase/functions/cleanup-media) to -- pick up and remove later, whenever a message referencing one is deleted. -- Deleting the message row itself still happens immediately, same as today. +-- +-- `source` is written by the message's author, so it is only trusted for +-- what the message may own: an object of the chats_assets bucket inside the +-- message's own room folder (`/...`, or the room's legacy v1 bigint +-- folder), not referenced by any other message. Anything else (another +-- room, another bucket, an external URL) is left alone -- the cleanup +-- function deletes with the service role, which bypasses storage RLS. -- SECURITY DEFINER: chats.media_pending_deletion is closed to clients. CREATE OR REPLACE FUNCTION chats.queue_media_for_deletion() RETURNS TRIGGER @@ -121,21 +294,47 @@ DECLARE idx int; rest text; slash_idx int; + v_bucket text; + v_path text; + v_folder text; + v_legacy uuid; BEGIN - IF OLD.source IS NOT NULL THEN - idx := position(marker IN OLD.source); - IF idx > 0 THEN - rest := substring(OLD.source FROM idx + char_length(marker)); - slash_idx := position('/' IN rest); - IF slash_idx > 0 THEN - INSERT INTO chats.media_pending_deletion ("bucketId", path) - VALUES ( - substring(rest FROM 1 FOR slash_idx - 1), - substring(rest FROM slash_idx + 1) - ); - END IF; + IF OLD.source IS NULL THEN + RETURN OLD; + END IF; + idx := position(marker IN OLD.source); + IF idx = 0 THEN + RETURN OLD; + END IF; + rest := split_part(split_part(substring(OLD.source FROM idx + char_length(marker)), '?', 1), '#', 1); + slash_idx := position('/' IN rest); + IF slash_idx = 0 THEN + RETURN OLD; + END IF; + v_bucket := substring(rest FROM 1 FOR slash_idx - 1); + v_path := substring(rest FROM slash_idx + 1); + IF v_bucket <> 'chats_assets' + OR v_path !~ '^[^/]+/[^/]+$' + OR v_path ~ '(^|/)\.\.?(/|$)' THEN + RETURN OLD; + END IF; + + v_folder := split_part(v_path, '/', 1); + IF v_folder <> OLD."roomId"::text THEN + IF v_folder ~ '^[0-9]{1,18}$' AND to_regclass('chats.room_id_migration_map') IS NOT NULL THEN + EXECUTE 'SELECT new_id FROM chats.room_id_migration_map WHERE old_id = $1' + INTO v_legacy USING v_folder::bigint; END IF; + IF v_legacy IS DISTINCT FROM OLD."roomId" THEN + RETURN OLD; + END IF; + END IF; + + IF EXISTS (SELECT 1 FROM chats.messages m WHERE m.source = OLD.source) THEN + RETURN OLD; END IF; + + INSERT INTO chats.media_pending_deletion ("bucketId", path) VALUES (v_bucket, v_path); RETURN OLD; END; $$ LANGUAGE plpgsql; @@ -192,8 +391,10 @@ CREATE CONSTRAINT TRIGGER delete_room_if_empty_trigger -- they can be routed to their room. -- -- Who may join the channel is decided by the RLS policies on --- realtime.messages (03_database_policy.sql). Requires Realtime with --- Broadcast from Database support (Supabase cloud; self-hosted +-- realtime.messages (03_database_policy.sql). Clients can't send on it: only +-- the database does, so every event on it is authoritative (the typing +-- indicator has its own `chats:room::typing` channel). Requires +-- Realtime with Broadcast from Database support (Supabase cloud; self-hosted -- supabase/realtime >= v2.33). -- -- A failure to broadcast must never lose the message itself: it is reported @@ -228,3 +429,154 @@ drop trigger if exists broadcast_message_change_trigger on chats.messages; CREATE TRIGGER broadcast_message_change_trigger AFTER INSERT OR UPDATE OR DELETE ON chats.messages FOR EACH ROW EXECUTE FUNCTION chats.broadcast_message_change(); + +-- ---------------------------------------------------------------------------- +-- Room and membership changes, per user +-- +-- Every member of a room is told, on their own private channel +-- (`chats:user:`, joinable by that user only), that the room changed +-- (`room_change` event, payload {"roomId", "removed"}): the room was +-- updated, someone joined or left it, a role changed. "removed" is true for +-- the user who was removed (or left, or whose room was deleted). Clients +-- refetch the room through RLS. Replaces Postgres Changes on chats.rooms and +-- chats.room_members, whose DELETE events Realtime delivers to every +-- subscriber without RLS. +-- +-- Statement-level triggers with transition tables: creating a room with N +-- members sends N events, not one per member per inserted row. Failures are +-- reported as WARNINGs, like chats.broadcast_message_change(). +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION chats.send_room_change(p_room_id uuid, p_user_id uuid, p_removed boolean) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' +AS $$ +BEGIN + PERFORM realtime.send( + jsonb_build_object('roomId', p_room_id, 'removed', p_removed), + 'room_change', + 'chats:user:' || p_user_id::text, + true + ); +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'chats.send_room_change: % (room %, user %)', SQLERRM, p_room_id, p_user_id; +END; +$$; + +CREATE OR REPLACE FUNCTION chats.broadcast_members_upserted() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT DISTINCT m."roomId", m."userId" + FROM chats.room_members m + WHERE m."roomId" IN (SELECT "roomId" FROM changed_rows) + LOOP + PERFORM chats.send_room_change(r."roomId", r."userId", false); + END LOOP; + RETURN NULL; +END; +$$; + +-- A role change concerns every member (room info, admin actions); the +-- per-member columns ("lastReadAt", "pinnedAt", "mutedUntil") only the +-- member themselves -- marking a room as read must not make every other +-- member refetch it. +CREATE OR REPLACE FUNCTION chats.broadcast_members_updated() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT DISTINCT m."roomId", m."userId" + FROM chats.room_members m + WHERE m."roomId" IN ( + SELECT n."roomId" FROM changed_rows n + JOIN previous_rows o ON o."roomId" = n."roomId" AND o."userId" = n."userId" + WHERE o.role IS DISTINCT FROM n.role + ) + UNION + SELECT n."roomId", n."userId" FROM changed_rows n + LOOP + PERFORM chats.send_room_change(r."roomId", r."userId", false); + END LOOP; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION chats.broadcast_members_deleted() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT DISTINCT o."roomId", o."userId", true AS removed + FROM removed_rows o + UNION + SELECT DISTINCT m."roomId", m."userId", false + FROM chats.room_members m + WHERE m."roomId" IN (SELECT "roomId" FROM removed_rows) + LOOP + PERFORM chats.send_room_change(r."roomId", r."userId", r.removed); + END LOOP; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION chats.broadcast_rooms_updated() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT DISTINCT m."roomId", m."userId" + FROM chats.room_members m + WHERE m."roomId" IN (SELECT id FROM changed_rows) + LOOP + PERFORM chats.send_room_change(r."roomId", r."userId", false); + END LOOP; + RETURN NULL; +END; +$$; + +drop trigger if exists broadcast_members_inserted_trigger on chats.room_members; +CREATE TRIGGER broadcast_members_inserted_trigger + AFTER INSERT ON chats.room_members + REFERENCING NEW TABLE AS changed_rows + FOR EACH STATEMENT EXECUTE FUNCTION chats.broadcast_members_upserted(); + +drop trigger if exists broadcast_members_updated_trigger on chats.room_members; +CREATE TRIGGER broadcast_members_updated_trigger + AFTER UPDATE ON chats.room_members + REFERENCING OLD TABLE AS previous_rows NEW TABLE AS changed_rows + FOR EACH STATEMENT EXECUTE FUNCTION chats.broadcast_members_updated(); + +drop trigger if exists broadcast_members_deleted_trigger on chats.room_members; +CREATE TRIGGER broadcast_members_deleted_trigger + AFTER DELETE ON chats.room_members + REFERENCING OLD TABLE AS removed_rows + FOR EACH STATEMENT EXECUTE FUNCTION chats.broadcast_members_deleted(); + +drop trigger if exists broadcast_rooms_updated_trigger on chats.rooms; +CREATE TRIGGER broadcast_rooms_updated_trigger + AFTER UPDATE ON chats.rooms + REFERENCING NEW TABLE AS changed_rows + FOR EACH STATEMENT EXECUTE FUNCTION chats.broadcast_rooms_updated(); diff --git a/example/utils/sql/03_database_policy.sql b/example/utils/sql/03_database_policy.sql index 3a78dbd..b666980 100644 --- a/example/utils/sql/03_database_policy.sql +++ b/example/utils/sql/03_database_policy.sql @@ -128,14 +128,14 @@ AS $$ WHERE "roomId" = room_id AND "userId" = auth.uid(); $$; --- Storage helper: is the caller a member of the room whose id is the first --- folder of a `chats_assets` object path (`/`)? Returns false --- instead of raising for paths whose first folder isn't a uuid -- a bare --- `::uuid` cast inside a storage policy could otherwise fail queries on --- unrelated objects, since SQL doesn't guarantee the evaluation order of --- the policy's AND-ed conditions. -CREATE OR REPLACE FUNCTION chats.is_chat_member_path(object_name text) - RETURNS boolean +-- Storage helper: the room a `chats_assets` object path (`/`) +-- belongs to, from its first folder; NULL when it isn't a room folder. +-- Never raises for a folder that isn't a uuid -- a bare `::uuid` cast inside +-- a storage policy could otherwise fail queries on unrelated objects, since +-- SQL doesn't guarantee the evaluation order of the policy's AND-ed +-- conditions. +CREATE OR REPLACE FUNCTION chats.room_id_from_path(object_name text) + RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER @@ -146,7 +146,7 @@ DECLARE mapped uuid; BEGIN IF folder ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN - RETURN chats.is_chat_member(folder::uuid); + RETURN folder::uuid; END IF; -- Attachments uploaded before the v1 -> v2 migration live under the old -- bigint room id (`/`): resolve it through the migration @@ -154,19 +154,45 @@ BEGIN IF folder ~ '^[0-9]{1,18}$' AND to_regclass('chats.room_id_migration_map') IS NOT NULL THEN EXECUTE 'SELECT new_id FROM chats.room_id_migration_map WHERE old_id = $1' INTO mapped USING folder::bigint; - RETURN mapped IS NOT NULL AND chats.is_chat_member(mapped); + RETURN mapped; END IF; - RETURN false; + RETURN NULL; END; $$; --- Realtime helper: is the caller a member of the room a private channel --- topic (`chats:room:`) belongs to? False for any other topic, and --- for a malformed id (same safe-cast rationale as above). -CREATE OR REPLACE FUNCTION chats.is_chat_member_topic(topic text) +-- Storage helper: is the caller a member of the room of a `chats_assets` +-- object path? +CREATE OR REPLACE FUNCTION chats.is_chat_member_path(object_name text) RETURNS boolean - LANGUAGE plpgsql + LANGUAGE sql STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ + SELECT coalesce(chats.is_chat_member(chats.room_id_from_path(object_name)), false); +$$; + +-- Storage helper: is the caller an admin of the (group) room of a +-- `chats_assets` object path? Mirrors who may delete someone else's message. +CREATE OR REPLACE FUNCTION chats.is_chat_admin_path(object_name text) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ + SELECT coalesce(chats.chat_member_role(chats.room_id_from_path(object_name)) = 'admin', false); +$$; + +-- Realtime helper: the room a room channel topic belongs to -- +-- `chats:room:` (message changes, sent by the database only) or +-- `chats:room::typing` (typing indicator, sent by members) -- or +-- NULL for any other topic or a malformed id (same safe-cast rationale as +-- above). +CREATE OR REPLACE FUNCTION chats.room_id_from_topic(topic text, typing boolean) + RETURNS uuid + LANGUAGE plpgsql + IMMUTABLE SECURITY INVOKER SET search_path = '' AS $$ @@ -174,16 +200,86 @@ DECLARE room text; BEGIN IF topic IS NULL OR left(topic, 11) <> 'chats:room:' THEN - RETURN false; + RETURN NULL; END IF; room := substr(topic, 12); + IF typing THEN + IF right(room, 7) <> ':typing' THEN + RETURN NULL; + END IF; + room := left(room, -7); + END IF; IF room !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN - RETURN false; + RETURN NULL; END IF; - RETURN chats.is_chat_member(room::uuid); + RETURN room::uuid; END; $$; +-- Realtime helper: may the caller receive on a room channel topic (message +-- changes or typing indicator)? Members of the room only. +CREATE OR REPLACE FUNCTION chats.is_chat_member_topic(topic text) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY INVOKER + SET search_path = '' +AS $$ + SELECT coalesce(chats.is_chat_member( + coalesce(chats.room_id_from_topic(topic, false), chats.room_id_from_topic(topic, true)) + ), false); +$$; + +-- Realtime helper: may the caller send on a topic? Only on the typing +-- channel of a room they are a member of: the message channel carries +-- database events only, so a member can't forge them. +CREATE OR REPLACE FUNCTION chats.is_chat_typing_topic(topic text) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY INVOKER + SET search_path = '' +AS $$ + SELECT coalesce(chats.is_chat_member(chats.room_id_from_topic(topic, true)), false); +$$; + +-- Realtime helper: is the topic the caller's own user channel +-- (`chats:user:`, room and membership changes)? +CREATE OR REPLACE FUNCTION chats.is_own_user_topic(topic text) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY INVOKER + SET search_path = '' +AS $$ + SELECT auth.uid() IS NOT NULL AND topic = 'chats:user:' || auth.uid()::text; +$$; + +-- Realtime helpers: online status channels (`online-user-`, see +-- SupabaseChatCoreConfig.realtimeOnlineUserPrefixChannel). Any signed-in +-- user may watch any user's status (like profiles, readable by everyone), +-- only the user themselves may publish it. +CREATE OR REPLACE FUNCTION chats.is_presence_topic(topic text) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY INVOKER + SET search_path = '' +AS $$ + SELECT auth.uid() IS NOT NULL + AND coalesce(topic ~* '^online-user-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', false); +$$; + +CREATE OR REPLACE FUNCTION chats.is_own_presence_topic(topic text) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY INVOKER + SET search_path = '' +AS $$ + SELECT auth.uid() IS NOT NULL AND topic = 'online-user-' || auth.uid()::text; +$$; + -- Storage helper: is the first folder of an object path (`/`) -- the caller's own user id? Same safe-cast rationale as above. CREATE OR REPLACE FUNCTION chats.is_owner_path(object_name text) @@ -353,6 +449,7 @@ AS $$ DECLARE v_uid text := auth.uid()::text; v_room_id uuid; + v_created_at timestamptz; v_reactions jsonb; v_users jsonb; BEGIN @@ -360,13 +457,20 @@ BEGIN RAISE EXCEPTION 'invalid reaction' USING ERRCODE = '22023'; END IF; - SELECT "roomId", coalesce(reactions, '{}'::jsonb) - INTO v_room_id, v_reactions - FROM chats.messages - WHERE id = p_message_id - FOR UPDATE; + -- Authorize and update the very same row: (id, "createdAt") is the + -- primary key. Ids are unique (chats.message_ids); STRICT makes sure an + -- ambiguous id can never select one row and update another. + BEGIN + SELECT "roomId", "createdAt", coalesce(reactions, '{}'::jsonb) + INTO STRICT v_room_id, v_created_at, v_reactions + FROM chats.messages + WHERE id = p_message_id + FOR UPDATE; + EXCEPTION WHEN NO_DATA_FOUND OR TOO_MANY_ROWS THEN + RAISE EXCEPTION 'message not found' USING ERRCODE = 'P0002'; + END; - IF v_room_id IS NULL OR NOT chats.is_chat_member(v_room_id) THEN + IF NOT chats.is_chat_member(v_room_id) THEN RAISE EXCEPTION 'message not found' USING ERRCODE = 'P0002'; END IF; @@ -385,7 +489,7 @@ BEGIN UPDATE chats.messages SET reactions = CASE WHEN v_reactions = '{}'::jsonb THEN NULL ELSE v_reactions END - WHERE id = p_message_id; + WHERE id = p_message_id AND "createdAt" = v_created_at AND "roomId" = v_room_id; RETURN v_reactions; END; @@ -442,9 +546,16 @@ CREATE OR REPLACE FUNCTION chats.set_message_pinned(p_message_id uuid, p_pinned AS $$ DECLARE v_room_id uuid; + v_created_at timestamptz; BEGIN - SELECT "roomId" INTO v_room_id FROM chats.messages WHERE id = p_message_id; - IF v_room_id IS NULL OR NOT chats.is_chat_member(v_room_id) THEN + -- Same row binding as chats.toggle_message_reaction(). + BEGIN + SELECT "roomId", "createdAt" INTO STRICT v_room_id, v_created_at + FROM chats.messages WHERE id = p_message_id; + EXCEPTION WHEN NO_DATA_FOUND OR TOO_MANY_ROWS THEN + RAISE EXCEPTION 'message not found' USING ERRCODE = 'P0002'; + END; + IF NOT chats.is_chat_member(v_room_id) THEN RAISE EXCEPTION 'message not found' USING ERRCODE = 'P0002'; END IF; IF NOT chats.is_chat_admin(v_room_id) THEN @@ -452,7 +563,7 @@ BEGIN END IF; UPDATE chats.messages SET pinned = CASE WHEN p_pinned THEN true END - WHERE id = p_message_id; + WHERE id = p_message_id AND "createdAt" = v_created_at AND "roomId" = v_room_id; END; $$; @@ -561,13 +672,21 @@ CREATE POLICY "chats.room_members_grant_read" TO authenticated USING (chats.is_chat_member("roomId")); --- Role changes. ("lastReadAt" goes through chats.mark_room_as_read().) +-- Role changes, by the admins of a group room. Only the role column is +-- writable at all (column privileges in 99_database_schema_permission.sql): +-- moving a membership to another room or user would bypass the INSERT rules +-- above (e.g. adding a third member to a direct room). "lastReadAt", +-- "pinnedAt" and "mutedUntil" go through chats.mark_room_as_read(), +-- chats.pin_room() and chats.mute_room(). CREATE POLICY "chats.room_members_grant_update" ON chats.room_members AS PERMISSIVE FOR UPDATE TO authenticated - USING (chats.is_chat_admin("roomId")); + USING ( + chats.chat_member_role("roomId") = 'admin' + AND EXISTS (SELECT 1 FROM chats.rooms r WHERE r.id = "roomId" AND r.type = 'group') + ); -- Covers both "leave a room" (deleting your own row) and "remove a member" -- (a group admin deleting someone else's row). A room left without members @@ -615,14 +734,23 @@ CREATE POLICY "chats.messages_grant_delete" ); -- ---------------------------------------------------------------------------- --- Realtime: private room channels (`chats:room:`) +-- Realtime: private channels -- --- Realtime authorizes joining a private channel (receiving its broadcasts, --- incl. the message changes sent by chats.broadcast_message_change) against --- SELECT policies on realtime.messages, and sending to it (typing --- indicator) against INSERT policies. Both are limited to room members. --- Only topics starting with `chats:room:` are matched, so policies other --- apps define on realtime.messages are unaffected. +-- Realtime authorizes joining a private channel (receiving its broadcasts +-- and presence) against SELECT policies on realtime.messages, and sending +-- to it (broadcast, presence tracking) against INSERT policies: +-- +-- chats:room: message changes, sent by the database only +-- (chats.broadcast_message_change): members +-- receive, nobody sends. +-- chats:room::typing typing indicator: members receive and send. +-- chats:user: room/membership changes of that user +-- (chats.send_room_change): the user only. +-- online-user- online status (Presence): any signed-in +-- user watches, only publishes. +-- +-- Only these topic shapes are matched, so policies other apps define on +-- realtime.messages are unaffected. -- ---------------------------------------------------------------------------- DROP POLICY IF EXISTS "chats.realtime_room_read" ON realtime.messages; CREATE POLICY "chats.realtime_room_read" @@ -630,7 +758,11 @@ CREATE POLICY "chats.realtime_room_read" AS PERMISSIVE FOR SELECT TO authenticated - USING (chats.is_chat_member_topic((SELECT realtime.topic()))); + USING ( + chats.is_chat_member_topic((SELECT realtime.topic())) + OR chats.is_own_user_topic((SELECT realtime.topic())) + OR chats.is_presence_topic((SELECT realtime.topic())) + ); DROP POLICY IF EXISTS "chats.realtime_room_write" ON realtime.messages; CREATE POLICY "chats.realtime_room_write" @@ -639,6 +771,6 @@ CREATE POLICY "chats.realtime_room_write" FOR INSERT TO authenticated WITH CHECK ( - extension = 'broadcast' - AND chats.is_chat_member_topic((SELECT realtime.topic())) + (extension = 'broadcast' AND chats.is_chat_typing_topic((SELECT realtime.topic()))) + OR (extension = 'presence' AND chats.is_own_presence_topic((SELECT realtime.topic()))) ); diff --git a/example/utils/sql/04_storage.sql b/example/utils/sql/04_storage.sql index 11a4fd5..03e6f75 100644 --- a/example/utils/sql/04_storage.sql +++ b/example/utils/sql/04_storage.sql @@ -1,6 +1,10 @@ -- Storage buckets and policies for chat attachments and user avatars. -- --- chats_assets// readable/writable by room members. +-- chats_assets// readable and uploadable by room +-- members; overwritten only by its +-- uploader, deleted by its uploader or +-- a group admin (the same rules as the +-- message it belongs to). -- chats_user_avatar// readable by any authenticated user, -- writable by its owner only. -- @@ -36,16 +40,23 @@ using ( DROP policy IF EXISTS "storage.object_grant_update_auth_chats_assets" ON storage.objects; +-- An attachment backs its author's message, which only the author may edit: +-- another member overwriting it (upsert) would change what the message +-- shows under its author's name. create policy "storage.object_grant_update_auth_chats_assets" on storage.objects for update to authenticated using ( bucket_id = 'chats_assets' and + owner_id = (select auth.uid()::text) + and chats.is_chat_member_path(name)) with check ( bucket_id = 'chats_assets' and + owner_id = (select auth.uid()::text) + and chats.is_chat_member_path(name)); DROP policy IF EXISTS "storage.object_grant_delete_auth_chats_assets" @@ -56,7 +67,9 @@ to authenticated using ( bucket_id = 'chats_assets' and - chats.is_chat_member_path(name)); + chats.is_chat_member_path(name) + and + (owner_id = (select auth.uid()::text) or chats.is_chat_admin_path(name))); DROP policy IF EXISTS "storage.object_grant_create_auth_chats_user_avatar" ON storage.objects; diff --git a/example/utils/sql/06_cleanup_media_schedule.sql b/example/utils/sql/06_cleanup_media_schedule.sql index c24edf6..fd73c4c 100644 --- a/example/utils/sql/06_cleanup_media_schedule.sql +++ b/example/utils/sql/06_cleanup_media_schedule.sql @@ -37,7 +37,9 @@ BEGIN ), body := '{}'::jsonb ) - WHERE EXISTS (SELECT 1 FROM chats.media_pending_deletion WHERE "processedAt" IS NULL); + -- Same rows cleanup-media picks up: rows left aside after too many + -- failed attempts (see "lastError") don't keep the job firing. + WHERE EXISTS (SELECT 1 FROM chats.media_pending_deletion WHERE "processedAt" IS NULL AND attempts < 5); $sql$ ); END diff --git a/example/utils/sql/99_database_schema_permission.sql b/example/utils/sql/99_database_schema_permission.sql index 797c7d1..9a49ff1 100644 --- a/example/utils/sql/99_database_schema_permission.sql +++ b/example/utils/sql/99_database_schema_permission.sql @@ -17,6 +17,19 @@ BEGIN END $do$; REVOKE ALL ON TABLE chats.media_pending_deletion FROM anon, authenticated; +REVOKE ALL ON TABLE chats.message_ids FROM anon, authenticated; + +-- Column privileges: UPDATE policies say which rows a user may change, these +-- say which columns. Without them a room admin (or either member of a direct +-- room) could rewrite a membership's room or user, or a room's type, and +-- reach states the INSERT rules forbid (e.g. a third member in a direct +-- room); a user could set their own global role. +REVOKE UPDATE ON TABLE chats.rooms FROM anon, authenticated; +GRANT UPDATE (name, "imageUrl", metadata, "updatedAt") ON TABLE chats.rooms TO authenticated; +REVOKE UPDATE ON TABLE chats.room_members FROM anon, authenticated; +GRANT UPDATE (role) ON TABLE chats.room_members TO authenticated; +REVOKE UPDATE ON TABLE chats.users FROM anon, authenticated; +GRANT UPDATE ("firstName", "lastName", "imageUrl", metadata, "updatedAt", "lastSeen") ON TABLE chats.users TO authenticated; REVOKE ALL ON FUNCTION chats.create_room(text, uuid[], text, text, jsonb, text) FROM anon; REVOKE ALL ON FUNCTION chats.mark_room_as_read(uuid) FROM anon; REVOKE ALL ON FUNCTION chats.toggle_message_reaction(uuid, text) FROM anon; @@ -31,14 +44,25 @@ REVOKE ALL ON FUNCTION chats.set_message_pinned(uuid, boolean) FROM anon; REVOKE ALL ON FUNCTION chats.handle_new_user() FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION chats.update_last_messages() FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION chats.set_message_status_to_sent() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.protect_message_columns() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.attest_forward_origin() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.claim_message_id() FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION chats.queue_media_for_deletion() FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION chats.delete_room_if_empty() FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION chats.broadcast_message_change() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.send_room_change(uuid, uuid, boolean) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.broadcast_members_upserted() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.broadcast_members_updated() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.broadcast_members_deleted() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.broadcast_rooms_updated() FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION chats.ensure_messages_partitions_rls() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.secure_new_messages_partitions() FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION chats.is_chat_member(uuid) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION chats.is_chat_admin(uuid) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION chats.chat_member_role(uuid) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.room_id_from_path(text) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION chats.is_chat_member_path(text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.is_chat_admin_path(text) FROM PUBLIC, anon; -- The blanket grants above also reach the chats.messages partitions, which -- must only be accessed through the parent table: take them back. diff --git a/lib/src/class/chat_forward_origin.dart b/lib/src/class/chat_forward_origin.dart index 6ee5826..07bee86 100644 --- a/lib/src/class/chat_forward_origin.dart +++ b/lib/src/class/chat_forward_origin.dart @@ -4,7 +4,14 @@ import 'package:meta/meta.dart'; import '../util.dart'; /// Where a forwarded message comes from, stored in its metadata under -/// [metadataKey] by `SupabaseChatCore.forwardMessages`. +/// [metadataKey]. +/// +/// Written by the database, never by clients: `SupabaseChatCore.forwardMessages` +/// only names the forwarded message ([claim]), and the +/// `chats.attest_forward_origin` trigger replaces that with the source's +/// author, author name and date (or the source's own origin, when it is +/// itself a forward), after checking the forwarder can read it. Any other +/// value is rejected, so an origin can't be made up. /// /// The author's name is a snapshot taken when forwarding: the recipients may /// not be able to read the original author's profile (they don't share a @@ -41,7 +48,15 @@ class ChatForwardOrigin { /// When the original message was sent, if known. final DateTime? createdAt; - /// The metadata value of this origin. + /// The metadata value a client stores under [metadataKey] when forwarding + /// message [sourceMessageId] of room [sourceRoomId]: the database replaces + /// it with the attested origin. + static Map claim({ + required String sourceRoomId, + required String sourceMessageId, + }) => {'sourceRoomId': sourceRoomId, 'sourceMessageId': sourceMessageId}; + + /// The metadata value of this origin, as stored by the database. Map toJson() => { 'authorId': authorId, if (authorName != null) 'authorName': authorName, diff --git a/lib/src/class/presence_watch.dart b/lib/src/class/presence_watch.dart index b414a7b..5d27665 100644 --- a/lib/src/class/presence_watch.dart +++ b/lib/src/class/presence_watch.dart @@ -17,9 +17,13 @@ class PresenceWatch { required this.uid, required this.onEmpty, }) { - _channel = client.channel(topic) - ..onPresenceSync((_) => _sync()) - ..subscribe(); + // Private: watching requires a signed-in user, and only [uid] may + // publish on the topic (RLS policies on realtime.messages), so any + // presence on it is [uid]'s own. + _channel = + client.channel(topic, opts: const RealtimeChannelConfig(private: true)) + ..onPresenceSync((_) => _sync()) + ..subscribe(); } /// Client the channel lives on. @@ -42,7 +46,7 @@ class PresenceWatch { void _sync() { final online = _channel.presenceState().any( - (state) => state.presences.any((p) => p.payload['uid'] == uid), + (state) => state.presences.isNotEmpty, ); final status = online ? UserOnlineStatus.online : UserOnlineStatus.offline; if (status == _status) return; diff --git a/lib/src/class/room_channel.dart b/lib/src/class/room_channel.dart index ae68922..0900de4 100644 --- a/lib/src/class/room_channel.dart +++ b/lib/src/class/room_channel.dart @@ -16,21 +16,24 @@ typedef MessageChangeListener = Map oldRecord, ); -/// A room's private Supabase Realtime **Broadcast** channel -/// (``), shared by every controller open on that room -/// (Realtime only allows one join per topic per connection). It carries: +/// A room's private Supabase Realtime **Broadcast** channels, shared by every +/// controller open on that room (Realtime only allows one join per topic per +/// connection): /// -/// - **message changes**, sent by the database itself (the -/// `chats.broadcast_message_change` trigger calls +/// - ``: **message changes**, sent by the database itself +/// (the `chats.broadcast_message_change` trigger calls /// `realtime.broadcast_changes`). Postgres Changes can't be used for /// messages: `chats.messages` is partitioned, and Postgres Changes /// reports rows under their physical partition, never matching a -/// subscription on the parent table; -/// - the **typing indicator**, sent by clients. +/// subscription on the parent table. Clients can't send on it, so every +/// event on it comes from the database; +/// - `:typing`: the **typing indicator**, sent by clients. /// -/// The channel is private: Realtime authorizes joining (reading) and +/// Both channels are private: Realtime authorizes joining (reading) and /// sending against RLS policies on `realtime.messages`, which only let room -/// members in (see 03_database_policy.sql). +/// members in, and only let them send on the typing channel (see +/// 03_database_policy.sql). A typing event's `uid` is still chosen by the +/// sending member: treat it as display data only. /// /// ## Typing indicator (issue #38) /// @@ -65,42 +68,71 @@ class RoomChannel { topic, opts: const RealtimeChannelConfig(private: true), ); - _channel.onBroadcast(event: typingEvent, callback: _onTyping); for (final operation in const ['INSERT', 'UPDATE', 'DELETE']) { _channel.onBroadcast( event: operation, callback: (message) => _onMessageChange(operation, message), ); } - _channel.subscribe((status, error) { - // Statuses arriving after the release (e.g. the session was closed - // while Realtime was re-authorizing the channel) concern nobody. - if (_disposed) return; - _subscribed = status == RealtimeSubscribeStatus.subscribed; - if (status == RealtimeSubscribeStatus.channelError && - '$error'.contains('Unauthorized')) { - // Not (or no longer) a member: retrying can't succeed, and the - // client would otherwise keep rejoining with backoff for as long as - // the connection lives, piling up subscription errors server side. - _unauthorized = true; - unawaited(client.removeChannel(_channel)); - } - if (status == RealtimeSubscribeStatus.channelError || - status == RealtimeSubscribeStatus.timedOut) { - for (final listener in _errorListeners.toList()) { - listener(error ?? 'Realtime channel $topic: ${status.name}'); - } - } - }); + _channel.subscribe( + (status, error) => _onStatus(_channel, status, error, (subscribed) {}), + ); + _typingChannel = client.channel( + '$topic$typingTopicSuffix', + opts: const RealtimeChannelConfig(private: true), + ); + _typingChannel.onBroadcast(event: typingEvent, callback: _onTyping); + _typingChannel.subscribe( + (status, error) => _onStatus( + _typingChannel, + status, + error, + (subscribed) => _subscribed = subscribed, + ), + ); } /// Broadcast event name of typing updates. static const typingEvent = 'typing'; + /// Suffix of the typing channel topic, appended to [topic]. + static const typingTopicSuffix = ':typing'; + + void _onStatus( + RealtimeChannel channel, + RealtimeSubscribeStatus status, + Object? error, + void Function(bool subscribed) setSubscribed, + ) { + // Statuses arriving after the release (e.g. the session was closed + // while Realtime was re-authorizing the channel) concern nobody. + if (_disposed) return; + setSubscribed(status == RealtimeSubscribeStatus.subscribed); + if (status == RealtimeSubscribeStatus.channelError && + '$error'.contains('Unauthorized')) { + // Not (or no longer) a member: retrying can't succeed, and the client + // would otherwise keep rejoining with backoff for as long as the + // connection lives, piling up subscription errors server side. + _unauthorized = true; + unawaited(client.removeChannel(channel)); + } + if (status == RealtimeSubscribeStatus.channelError || + status == RealtimeSubscribeStatus.timedOut) { + _notifyError(error ?? 'Realtime channel $topic: ${status.name}'); + } + } + + void _notifyError(Object error) { + for (final listener in _errorListeners.toList()) { + listener(error); + } + } + /// Client the channel lives on. final SupabaseClient client; - /// Channel topic (``). + /// Message channel topic (``); the typing channel is + /// [topic] + [typingTopicSuffix]. final String topic; /// Called once the last listener is removed, right before the channel is @@ -117,6 +149,7 @@ class RoomChannel { final Duration expiryTimeout; late final RealtimeChannel _channel; + late final RealtimeChannel _typingChannel; bool _subscribed = false; bool _disposed = false; bool _unauthorized = false; @@ -129,13 +162,27 @@ class RoomChannel { String? _typingAs; Timer? _idleTimer; - /// Whether the channel is currently joined. + /// Whether the typing channel is currently joined. bool get isSubscribed => _subscribed; - /// Whether Realtime refused the channel (the user isn't a member of the - /// room). The channel is released and won't be retried. + /// Whether Realtime refused the channel, or the user was removed from the + /// room (see [revoke]). The channels are released and won't be retried. bool get isUnauthorized => _unauthorized; + /// Leaves both channels because the logged-in user is no longer a member + /// of the room (announced on their user channel), reporting it to the + /// error listeners. Realtime only authorizes a private channel when it is + /// joined: without this, an open channel would keep receiving the room's + /// messages until the next re-authorization. + Future revoke() async { + if (_disposed || _unauthorized) return; + _unauthorized = true; + _subscribed = false; + _notifyError('Removed from the room: channel $topic closed'); + await client.removeChannel(_channel); + await client.removeChannel(_typingChannel); + } + /// Ids of the remote users currently typing. Set get typingUserIds => Set.unmodifiable(_typing.keys); @@ -179,13 +226,14 @@ class RoomChannel { Future _sendTyping(String uid, bool typing) async { if (!_subscribed || _disposed) return; try { - await _channel.sendBroadcastMessage( + await _typingChannel.sendBroadcastMessage( event: typingEvent, payload: {'uid': uid, 'typing': typing}, ); - } on Object { + } on Object catch (_) { // Typing indicators are best effort: a dropped event is corrected by // the next one, or by the receivers' expiry timer. + return; } } @@ -271,6 +319,9 @@ class RoomChannel { _typingListeners.clear(); _messageListeners.clear(); _errorListeners.clear(); - if (!_unauthorized) await client.removeChannel(_channel); + if (!_unauthorized) { + await client.removeChannel(_channel); + await client.removeChannel(_typingChannel); + } } } diff --git a/lib/src/class/supabase_chat_controller.dart b/lib/src/class/supabase_chat_controller.dart index f0e1177..aa13079 100644 --- a/lib/src/class/supabase_chat_controller.dart +++ b/lib/src/class/supabase_chat_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:supabase_flutter/supabase_flutter.dart' show PostgrestException; import '../util.dart'; import 'chat_room.dart'; @@ -369,7 +370,14 @@ class SupabaseChatController } unawaited(endTyping()); try { - await _chatCore.sendMessage(local, _room.id); + try { + await _chatCore.sendMessage(local, _room.id); + } on SupabaseChatCoreRequestException catch (e) { + // Message ids are unique: a retry of a send that did reach the + // server (the response was lost) is refused as a duplicate. It is + // sent, as long as the stored message is our own. + if (!_isDuplicateId(e) || !await _isStoredAsOwn(local.id)) rethrow; + } final sent = _findById(local.id); if (sent != null && sent.status == core.MessageStatus.sending) { await _inner.updateMessage( @@ -392,6 +400,16 @@ class SupabaseChatController } } + static bool _isDuplicateId(SupabaseChatCoreRequestException e) { + final cause = e.cause; + return cause is PostgrestException && cause.code == '23505'; + } + + Future _isStoredAsOwn(String messageId) async { + final stored = await _chatCore.message(_room.id, messageId); + return stored != null && stored.authorId == _currentUserId; + } + /// Sends again a message whose [sendMessage] failed (status `error`). Future retryMessage(core.Message message) => sendMessage(message.copyWith(failedAt: null, createdAt: DateTime.now())); diff --git a/lib/src/class/supabase_chat_core.dart b/lib/src/class/supabase_chat_core.dart index 1626a77..8ffc89a 100644 --- a/lib/src/class/supabase_chat_core.dart +++ b/lib/src/class/supabase_chat_core.dart @@ -19,6 +19,7 @@ import 'room_channel.dart'; import 'supabase_chat_core_config.dart'; import 'supabase_chat_core_exception.dart'; import 'upload_asset_result.dart'; +import 'user_channel.dart'; import 'user_online_status.dart'; /// Provides access to Supabase chat data. @@ -177,6 +178,9 @@ class SupabaseChatCore { await channel.dispose(); } _roomChannels.clear(); + final userChannel = _userChannel; + _userChannel = null; + await userChannel?.dispose(); } /// Reloads [loggedUser] from Supabase and returns it. @@ -188,8 +192,12 @@ class SupabaseChatCore { void _startOwnPresence(String uid) { _presenceUserId = uid; + // Private: only the user themselves may publish on their own status + // channel (RLS policies on realtime.messages), so nobody can make + // someone else appear online, and the anon key can't watch anyone. final channel = _ownPresenceChannel = client.channel( '${config.realtimeOnlineUserPrefixChannel}$uid', + opts: const RealtimeChannelConfig(private: true), ); channel.subscribe((status, error) async { // A late status of a channel already replaced must not change the @@ -282,14 +290,21 @@ class SupabaseChatCore { final Map _roomChannels = {}; - /// Shared private Broadcast channel of room [roomId] (message changes and + /// Shared private Broadcast channels of room [roomId] (message changes and /// typing indicator). One per room per [SupabaseChatCore], however many /// controllers are open on that room: Realtime only allows one join per - /// topic per connection. + /// topic per connection. They are closed as soon as the logged-in user is + /// removed from the room (see [RoomChannel.revoke]). @internal RoomChannel roomChannel(String roomId) => _roomChannels.putIfAbsent(roomId, () { late final RoomChannel created; + void onRoomChange(String changedRoomId, bool removed) { + if (removed && changedRoomId == roomId) unawaited(created.revoke()); + } + + final userChannel = _userChannelOrNull(); + userChannel?.addListener(onRoomChange); return created = RoomChannel( client: client, topic: '${config.realtimeRoomChannelPrefix}$roomId', @@ -297,10 +312,34 @@ class SupabaseChatCore { // channel of the same room may already be registered. onEmpty: () { if (_roomChannels[roomId] == created) _roomChannels.remove(roomId); + if (userChannel != null) { + unawaited(userChannel.removeListener(onRoomChange)); + } }, ); }); + UserChannel? _userChannel; + + /// The logged-in user's shared private channel of room changes, or `null` + /// when signed out. + UserChannel? _userChannelOrNull() { + final uid = loggedUserId; + if (uid == null) return null; + final existing = _userChannel; + if (existing != null) return existing; + late final UserChannel created; + return _userChannel = created = UserChannel( + client: client, + topic: '${config.realtimeUserChannelPrefix}$uid', + // Only unregister itself: once [closeSession] disposed it, a new + // channel may already be registered. + onEmpty: () { + if (_userChannel == created) _userChannel = null; + }, + ); + } + // --------------------------------------------------------------------------- // Storage // --------------------------------------------------------------------------- @@ -371,11 +410,49 @@ class SupabaseChatCore { }); } - /// Headers authenticating HTTP requests to Supabase (e.g. to load - /// attachments from [getAssetUrl] URLs with `Image.network` or `http`). - Map get httpSupabaseHeaders => { - 'Authorization': 'Bearer ${client.auth.currentSession?.accessToken}', - }; + /// Headers authenticating HTTP requests to Supabase. + /// + /// The access token is sent whatever the URL: never attach these headers + /// to a URL read from chat data (avatars, room images, message sources), + /// which any user can point at their own server. Use + /// [httpSupabaseHeadersFor] instead. + @Deprecated( + 'Leaks the access token to any URL it is attached to: use ' + 'httpSupabaseHeadersFor(url)', + ) + Map get httpSupabaseHeaders { + final token = client.auth.currentSession?.accessToken; + return token == null ? const {} : {'Authorization': 'Bearer $token'}; + } + + /// Headers authenticating a request to [url] (e.g. to load an attachment + /// with `Image.network(url, headers: ...)` or `http.get`): the access + /// token, only when [url] is an authenticated object URL of this project's + /// Storage (see [getAssetUrl]); no headers for any other URL, or when + /// signed out. + /// + /// Avatars, room images and message sources are written by users: an + /// unconditional `Authorization` header would hand the viewer's session to + /// whatever server such a URL points at. + Map httpSupabaseHeadersFor(String url) { + final token = client.auth.currentSession?.accessToken; + if (token == null || !isStorageObjectUrl(url)) return const {}; + return {'Authorization': 'Bearer $token'}; + } + + /// Whether [url] is an authenticated object URL of this project's Storage + /// (same scheme, host, port and `/object/authenticated/` path prefix as + /// [getAssetUrl]). + bool isStorageObjectUrl(String url) { + final target = Uri.tryParse(url); + final storage = Uri.tryParse('${client.storage.url}/object/authenticated/'); + if (target == null || storage == null || !target.hasScheme) return false; + return target.scheme == storage.scheme && + target.host == storage.host && + target.port == storage.port && + target.path.startsWith(storage.path) && + !target.pathSegments.contains('..'); + } // --------------------------------------------------------------------------- // Errors @@ -708,72 +785,30 @@ class SupabaseChatCore { } }); - controller = _sessionStream(() { - final channel = client - .channel(_uniqueTopic('${config.schema}:room:$roomId')) - .onPostgresChanges( - event: PostgresChangeEvent.update, - schema: config.schema, - table: config.roomsTableName, - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'id', - value: roomId, - ), - callback: (_) => refresh(), - ) - .onPostgresChanges( - event: PostgresChangeEvent.insert, - schema: config.schema, - table: config.roomMembersTableName, - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'roomId', - value: roomId, - ), - callback: (_) => refresh(), - ) - .onPostgresChanges( - event: PostgresChangeEvent.update, - schema: config.schema, - table: config.roomMembersTableName, - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'roomId', - value: roomId, - ), - callback: (_) => refresh(), - ) - // DELETE events can't be filtered (Realtime only ships the old - // row's primary key for them): refetch on any, cheap enough. - .onPostgresChanges( - event: PostgresChangeEvent.delete, - schema: config.schema, - table: config.roomMembersTableName, - callback: (payload) { - final old = payload.oldRecord; - if (old['roomId'] == null || old['roomId'] == roomId) { - refresh(); - } - }, - ) - .onPostgresChanges( - event: PostgresChangeEvent.delete, - schema: config.schema, - table: config.roomsTableName, - callback: (payload) { - if (payload.oldRecord['id'] == roomId) refresh(); - }, - ) - .subscribe( - (status, error) => _reportSubscribeError(controller, status, error), - ); - refresh(); - return channel; - }); + controller = _sessionStream( + () => _listenRoomChanges(controller, (changedRoomId, removed) { + if (changedRoomId == roomId) refresh(); + }, onListening: refresh), + ); return controller.stream; } + /// Registers [listener] on the logged-in user's channel of room changes + /// (failures reported on [controller]) and returns its remover. + Future Function() _listenRoomChanges( + StreamController controller, + RoomChangeListener listener, { + void Function()? onListening, + }) { + final channel = _userChannelOrNull(); + if (channel == null) return () async {}; + void onError(RealtimeSubscribeStatus status, Object? error) => + _reportSubscribeError(controller, status, error); + channel.addListener(listener, onError: onError); + onListening?.call(); + return () => channel.removeListener(listener, onError: onError); + } + /// Returns a page of the rooms the logged-in user is a member of: the ones /// they pinned first, then most recently updated first. [filter] matches the room name /// (case-insensitive; direct rooms are named after the other member). @@ -840,73 +875,34 @@ class SupabaseChatCore { }); controller = _sessionStream( - () => client - .channel(_uniqueTopic('${config.schema}:rooms:$uid')) - .onPostgresChanges( - event: PostgresChangeEvent.update, - schema: config.schema, - table: config.roomsTableName, - callback: (payload) => upsert(payload.newRecord['id'].toString()), - ) - .onPostgresChanges( - event: PostgresChangeEvent.delete, - schema: config.schema, - table: config.roomsTableName, - callback: (payload) { - final id = payload.oldRecord['id']; - if (id != null) remove(id.toString()); - }, - ) - .onPostgresChanges( - event: PostgresChangeEvent.insert, - schema: config.schema, - table: config.roomMembersTableName, - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'userId', - value: uid, - ), - callback: (payload) => - upsert(payload.newRecord['roomId'].toString()), - ) - // Unfilterable (see [room]): the old row only carries the - // primary key, which does include roomId/userId here. - .onPostgresChanges( - event: PostgresChangeEvent.delete, - schema: config.schema, - table: config.roomMembersTableName, - callback: (payload) { - final old = payload.oldRecord; - if (old['roomId'] == null) return; - if (old['userId'] == uid) { - remove(old['roomId'].toString()); - } else { - upsert(old['roomId'].toString()); - } - }, - ) - .subscribe( - (status, error) => _reportSubscribeError(controller, status, error), - ), + () => _listenRoomChanges(controller, (roomId, removed) { + if (removed) { + remove(roomId); + } else { + upsert(roomId); + } + }), ); return controller.stream; } - /// A stream controller whose Realtime channel, opened by [subscribe] when - /// the stream is listened to, is removed when the listener cancels or - /// when the session closes ([closeSession], sign out, user switch): the - /// stream then completes, so a listener never keeps receiving the data of - /// a user that is no longer signed in. - StreamController _sessionStream(RealtimeChannel Function() subscribe) { + /// A stream controller whose Realtime subscription, opened by [subscribe] + /// when the stream is listened to (it returns its closer), is closed when + /// the listener cancels or when the session closes ([closeSession], sign + /// out, user switch): the stream then completes, so a listener never keeps + /// receiving the data of a user that is no longer signed in. + StreamController _sessionStream( + Future Function() Function() subscribe, + ) { late final StreamController controller; - RealtimeChannel? channel; + Future Function()? unsubscribe; late final Future Function() closeForSession; Future release() async { _sessionClosers.remove(closeForSession); - final c = channel; - channel = null; - if (c != null) await client.removeChannel(c); + final close = unsubscribe; + unsubscribe = null; + if (close != null) await close(); } closeForSession = () async { @@ -918,7 +914,7 @@ class SupabaseChatCore { controller = StreamController( onListen: () { - channel = subscribe(); + unsubscribe = subscribe(); _sessionClosers.add(closeForSession); }, onCancel: release, @@ -987,13 +983,6 @@ class SupabaseChatCore { } } - static int _topicCounter = 0; - - /// Postgres changes channels can be named freely: a per-subscription - /// unique topic lets several listeners of the same data coexist on one - /// Realtime connection (which allows a single join per topic). - static String _uniqueTopic(String base) => '$base:${++_topicCounter}'; - // --------------------------------------------------------------------------- // Users // --------------------------------------------------------------------------- @@ -1161,18 +1150,21 @@ class SupabaseChatCore { /// Sends [message] to room [roomId]. Build [message] with one of /// `flutter_chat_core`'s `Message` factories (`Message.text`, /// `Message.image`, ...) and your own `id` (e.g. `const Uuid().v7()`). - /// `authorId` is always set to the logged-in user, and `createdAt` - /// defaults to now. Stores [message]'s `id` as-is, so a caller doing - /// optimistic UI (see `SupabaseChatController.sendMessage`) gets the exact - /// same row back from Realtime instead of a second, differently-id'd one. + /// `authorId` is always set to the logged-in user, and `createdAt` is + /// assigned by the database ([message]'s own is ignored: a device clock + /// can't decide where a message lands in everyone's timeline). Stores + /// [message]'s `id` as-is, so a caller doing optimistic UI (see + /// `SupabaseChatController.sendMessage`) gets the exact same row back from + /// Realtime instead of a second, differently-id'd one. Ids are unique: an + /// id already used by another message is rejected. Future sendMessage(core.Message message, String roomId) async { final uid = _requireUserId('sendMessage'); return _guard('sendMessage', () async { final row = messageToRow(message) + ..remove('createdAt') ..['id'] = message.id ..['roomId'] = roomId - ..['authorId'] = uid - ..['createdAt'] = formatChatDate(message.createdAt ?? DateTime.now()); + ..['authorId'] = uid; await client .schema(config.schema) .from(config.messagesTableName) @@ -1184,12 +1176,14 @@ class SupabaseChatCore { /// new messages of the logged-in user (who must be a member of both). /// /// Each copy keeps the content and the metadata, without the reply, - /// reactions, pin and edited mark, and stores its [ChatForwardOrigin]: - /// the original author and date, kept from the first forward when a - /// forwarded message is forwarded again. Attachments stored in the chat - /// assets bucket are copied into [toRoomId]'s folder: the members of the - /// destination can't read the files of a room they aren't in. Messages - /// that can't be persisted (see [messageToRow]) are skipped. + /// reactions, pin and edited mark, and gets its [ChatForwardOrigin]: the + /// original author and date, kept from the first forward when a forwarded + /// message is forwarded again. The origin is attested by the database from + /// the source message (the client only names it), so it can't be forged. + /// Attachments stored in the chat assets bucket are copied into + /// [toRoomId]'s folder: the members of the destination can't read the + /// files of a room they aren't in. Messages that can't be persisted (see + /// [messageToRow]) are skipped. Future forwardMessages( ChatRoom from, List messages, @@ -1209,21 +1203,20 @@ class SupabaseChatCore { ); if (sorted.isEmpty) return; return _guard('forwardMessages', () async { - final now = DateTime.now().toUtc(); final rows = >[]; - for (final (index, message) in sorted.indexed) { - final origin = - ChatForwardOrigin.of(message) ?? - ChatForwardOrigin( - authorId: message.authorId, - authorName: from.member(message.authorId)?.fullName, - createdAt: message.createdAt, - ); + for (final message in sorted) { final metadata = { ...?message.metadata, - ChatForwardOrigin.metadataKey: origin.toJson(), + ChatForwardOrigin.metadataKey: ChatForwardOrigin.claim( + sourceRoomId: from.id, + sourceMessageId: message.id, + ), }..remove('edited'); + // No createdAt: the database assigns it (clock_timestamp(), so the + // copies of one insert keep their order). final row = messageToRow(message) + ..remove('createdAt') + ..remove('updatedAt') ..remove('status') ..remove('reactions') ..remove('pinned') @@ -1231,10 +1224,6 @@ class SupabaseChatCore { ..['id'] = const Uuid().v7() ..['roomId'] = toRoomId ..['authorId'] = uid - // Spaced by a millisecond: the copies keep their order. - ..['createdAt'] = formatChatDate( - now.add(Duration(milliseconds: index)), - ) ..['metadata'] = metadata; final source = row['source']; if (source is String) { @@ -1267,14 +1256,20 @@ class SupabaseChatCore { /// Updates [message]'s content and metadata in room [roomId]. Only the /// author can update a message. Status, reactions and the pinned flag are /// left alone: see [markRoomAsRead], [toggleReaction] and - /// [setMessagePinned]. + /// [setMessagePinned]. The database keeps the message's id, room, author, + /// date and forward origin, and sets `updatedAt` itself when the content + /// changes. Future updateMessage(core.Message message, String roomId) => _guard('updateMessage', () async { final row = messageToRow(message) + ..remove('id') + ..remove('roomId') + ..remove('authorId') + ..remove('createdAt') + ..remove('updatedAt') ..remove('status') ..remove('reactions') - ..remove('pinned') - ..['updatedAt'] = formatChatDate(DateTime.now()); + ..remove('pinned'); await client .schema(config.schema) .from(config.messagesTableName) diff --git a/lib/src/class/supabase_chat_core_config.dart b/lib/src/class/supabase_chat_core_config.dart index b470bfb..8fe424c 100644 --- a/lib/src/class/supabase_chat_core_config.dart +++ b/lib/src/class/supabase_chat_core_config.dart @@ -62,6 +62,7 @@ class SupabaseChatCoreConfig { String? usersViewName, this.realtimeOnlineUserPrefixChannel = 'online-user-', this.realtimeRoomChannelPrefix = 'chats:room:', + this.realtimeUserChannelPrefix = 'chats:user:', this.chatAssetsBucket = 'chats_assets', this.userAvatarsBucket = 'chats_user_avatar', this.defaultRoomMetadata, @@ -98,17 +99,26 @@ class SupabaseChatCoreConfig { String get usersViewName => _usersViewName ?? usersTableName; final String? _usersViewName; - /// Prefix of the Presence channel used for a user's online status - /// (``). + /// Prefix of the private Presence channel used for a user's online status + /// (``). Any signed-in user may watch it, only `` may + /// publish on it (RLS policies on `realtime.messages`, which match the + /// default prefix). final String realtimeOnlineUserPrefixChannel; - /// Prefix of a room's private Realtime Broadcast channel - /// (``), carrying its message changes and typing - /// indicator. Must match the topic used by the - /// `chats.broadcast_message_change` trigger and the `realtime.messages` - /// RLS policies in the SQL scripts. + /// Prefix of a room's private Realtime Broadcast channels: `` + /// carries its message changes (sent by the database only) and + /// `:typing` its typing indicator (sent by members). Must + /// match the topics used by the `chats.broadcast_message_change` trigger and + /// the `realtime.messages` RLS policies in the SQL scripts. final String realtimeRoomChannelPrefix; + /// Prefix of a user's private Realtime Broadcast channel + /// (``), on which the database announces changes to the + /// user's rooms and memberships. Must match the topic used by the + /// `chats.send_room_change` trigger function and the `realtime.messages` + /// RLS policies in the SQL scripts. + final String realtimeUserChannelPrefix; + /// Storage bucket for message attachments (`/`). final String chatAssetsBucket; @@ -143,6 +153,7 @@ class SupabaseChatCoreConfig { String? usersViewName, String? realtimeOnlineUserPrefixChannel, String? realtimeRoomChannelPrefix, + String? realtimeUserChannelPrefix, String? chatAssetsBucket, String? userAvatarsBucket, ChatMetadataProvider? defaultRoomMetadata, @@ -162,6 +173,8 @@ class SupabaseChatCoreConfig { realtimeOnlineUserPrefixChannel ?? this.realtimeOnlineUserPrefixChannel, realtimeRoomChannelPrefix: realtimeRoomChannelPrefix ?? this.realtimeRoomChannelPrefix, + realtimeUserChannelPrefix: + realtimeUserChannelPrefix ?? this.realtimeUserChannelPrefix, chatAssetsBucket: chatAssetsBucket ?? this.chatAssetsBucket, userAvatarsBucket: userAvatarsBucket ?? this.userAvatarsBucket, defaultRoomMetadata: defaultRoomMetadata ?? this.defaultRoomMetadata, diff --git a/lib/src/class/user_channel.dart b/lib/src/class/user_channel.dart new file mode 100644 index 0000000..473ca91 --- /dev/null +++ b/lib/src/class/user_channel.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Signature of a [UserChannel] listener: room [roomId] changed (updated, +/// a member joined or left, a role changed). [removed] is true when the +/// logged-in user is no longer a member of it (left, removed, or the room +/// was deleted). +typedef RoomChangeListener = void Function(String roomId, bool removed); + +/// The logged-in user's private Supabase Realtime **Broadcast** channel +/// (``), shared by every `SupabaseChatCore.roomsChanges` and +/// `SupabaseChatCore.room` stream (Realtime only allows one join per topic +/// per connection). +/// +/// The database sends a `room_change` event on it to every member of a room +/// whenever the room or its membership changes (the `chats.send_room_change` +/// trigger function); only the user themselves may join it (RLS policies on +/// `realtime.messages`). Room and membership changes used to travel as +/// Postgres Changes, whose DELETE events Realtime delivers to every +/// subscriber without RLS. +@internal +class UserChannel { + /// Joins [topic] right away. + UserChannel({ + required this.client, + required this.topic, + required this.onEmpty, + }) { + _channel = client.channel( + topic, + opts: const RealtimeChannelConfig(private: true), + ); + _channel.onBroadcast(event: roomChangeEvent, callback: _onRoomChange); + _channel.subscribe((status, error) { + if (_disposed) return; + if (status == RealtimeSubscribeStatus.channelError || + status == RealtimeSubscribeStatus.timedOut) { + for (final listener in _errorListeners.toList()) { + listener(status, error); + } + } + }); + } + + /// Broadcast event name of room changes. + static const roomChangeEvent = 'room_change'; + + /// Client the channel lives on. + final SupabaseClient client; + + /// Channel topic (``). + final String topic; + + /// Called once the last listener is removed, right before the channel is + /// released. + final void Function() onEmpty; + + late final RealtimeChannel _channel; + bool _disposed = false; + final Set _listeners = {}; + final Set + _errorListeners = {}; + + void _onRoomChange(Map message) { + final payload = message['payload'] is Map + ? (message['payload'] as Map).cast() + : message; + final roomId = payload['roomId']?.toString(); + if (roomId == null) return; + final removed = payload['removed'] == true; + for (final listener in _listeners.toList()) { + listener(roomId, removed); + } + } + + /// Adds a room change [listener], and an optional [onError] called when + /// joining the channel fails or times out. + void addListener( + RoomChangeListener listener, { + void Function(RealtimeSubscribeStatus status, Object? error)? onError, + }) { + _listeners.add(listener); + if (onError != null) _errorListeners.add(onError); + } + + /// Removes [listener] (and its [onError]); releases the channel when it + /// was the last one. + Future removeListener( + RoomChangeListener listener, { + void Function(RealtimeSubscribeStatus status, Object? error)? onError, + }) async { + _listeners.remove(listener); + if (onError != null) _errorListeners.remove(onError); + // Already released (e.g. by `SupabaseChatCore.closeSession`). + if (_disposed || _listeners.isNotEmpty) return; + // Unregister first, so a channel requested while this one is still + // shutting down is a fresh one rather than this disposed instance. + onEmpty(); + await dispose(); + } + + /// Releases the channel. + Future dispose() async { + if (_disposed) return; + _disposed = true; + _listeners.clear(); + _errorListeners.clear(); + await client.removeChannel(_channel); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 940c335..ea845fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_supabase_chat_core description: > Actively maintained, community-driven Supabase BaaS for chat applications with an optional chat UI. -version: 2.0.0-rc.2 +version: 2.0.0-rc.3 homepage: https://flutter-supabase-chat-core.insideapp.it repository: https://github.com/insideapp-srl/flutter_supabase_chat_core issue_tracker: https://github.com/insideapp-srl/flutter_supabase_chat_core/issues @@ -14,8 +14,8 @@ topics: - flutter-chat-ui environment: - sdk: '>=3.8.0 <4.0.0' - flutter: '>=3.32.0' + sdk: '>=3.9.0 <4.0.0' + flutter: '>=3.35.0' dependencies: flutter: diff --git a/supabase/functions/cleanup-media/index.ts b/supabase/functions/cleanup-media/index.ts index 3869c46..9f5bd49 100644 --- a/supabase/functions/cleanup-media/index.ts +++ b/supabase/functions/cleanup-media/index.ts @@ -12,19 +12,37 @@ // curl -X POST "$SUPABASE_URL/functions/v1/cleanup-media" \ // -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" // -// Safe to call by anyone holding a valid JWT: it only ever deletes objects -// whose message is already gone, and it is idempotent. +// It deletes with the service role, which bypasses storage RLS, so it only +// trusts what a deleted message may own: the trigger queues only attachments +// of the chat assets bucket inside the message's own room folder, and this +// function checks that again (CHAT_ASSETS_BUCKET, `/`) +// before deleting -- anything else is marked processed without touching +// Storage. Calling it only drains that queue earlier, and it is idempotent. +// +// A row whose deletion keeps failing is retried MAX_ATTEMPTS times, then left +// aside with its last error ("lastError"), so it can never block the objects +// queued after it. import { createClient } from "jsr:@supabase/supabase-js@2"; const BATCH_SIZE = 500; +const MAX_ATTEMPTS = 5; const SCHEMA = Deno.env.get("CHAT_SCHEMA") ?? "chats"; +const ASSETS_BUCKET = Deno.env.get("CHAT_ASSETS_BUCKET") ?? "chats_assets"; +// `/`, nothing else. +const SAFE_PATH = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9]{1,18})\/[^/]+$/i; interface PendingRow { id: string; bucketId: string; path: string; + attempts: number; } +const isSafe = (row: PendingRow) => + row.bucketId === ASSETS_BUCKET && + SAFE_PATH.test(row.path) && + row.path.split("/").every((segment) => segment !== "." && segment !== ".."); + Deno.serve(async (req) => { if (req.method !== "POST" && req.method !== "GET") { return Response.json({ error: "method not allowed" }, { status: 405 }); @@ -42,12 +60,12 @@ Deno.serve(async (req) => { const supabase = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false }, }); + const queue = () => supabase.schema(SCHEMA).from("media_pending_deletion"); - const { data, error } = await supabase - .schema(SCHEMA) - .from("media_pending_deletion") - .select("id, bucketId, path") + const { data, error } = await queue() + .select("id, bucketId, path, attempts") .is("processedAt", null) + .lt("attempts", MAX_ATTEMPTS) .order("createdAt", { ascending: true }) .limit(BATCH_SIZE); @@ -57,35 +75,56 @@ Deno.serve(async (req) => { } const rows = (data ?? []) as PendingRow[]; - const byBucket = new Map(); - for (const row of rows) { - const list = byBucket.get(row.bucketId) ?? []; - list.push(row); - byBucket.set(row.bucketId, list); - } - + const now = () => new Date().toISOString(); const processed: string[] = []; - const failures: { bucketId: string; error: string }[] = []; + const failures: { id: string; error: string }[] = []; + + // Rows outside the chat assets folders are never deleted: closed as is. + const rejected = rows.filter((row) => !isSafe(row)); + for (const row of rejected) { + const { error: closeError } = await queue() + .update({ processedAt: now(), lastError: "rejected: not a chat attachment path" }) + .eq("id", row.id); + if (closeError) { + console.error(`cleanup-media: cannot close rejected row ${row.id}`, closeError); + failures.push({ id: row.id, error: closeError.message }); + } + } - for (const [bucketId, bucketRows] of byBucket) { + const safe = rows.filter(isSafe); + if (safe.length > 0) { // Storage's remove() treats already-missing objects as success, so a // retried batch never gets stuck on a file deleted by a previous run. - const { error: removeError } = await supabase.storage - .from(bucketId) - .remove(bucketRows.map((r) => r.path)); - if (removeError) { - console.error(`cleanup-media: cannot remove from ${bucketId}`, removeError); - failures.push({ bucketId, error: removeError.message }); - continue; + const { error: batchError } = await supabase.storage + .from(ASSETS_BUCKET) + .remove(safe.map((r) => r.path)); + if (!batchError) { + processed.push(...safe.map((r) => r.id)); + } else { + // One bad row must not fail the others: retry them one by one. + console.error("cleanup-media: batch remove failed, retrying per object", batchError); + for (const row of safe) { + const { error: rowError } = await supabase.storage + .from(ASSETS_BUCKET) + .remove([row.path]); + if (!rowError) { + processed.push(row.id); + continue; + } + failures.push({ id: row.id, error: rowError.message }); + const { error: countError } = await queue() + .update({ attempts: row.attempts + 1, lastError: rowError.message }) + .eq("id", row.id); + if (countError) { + console.error(`cleanup-media: cannot record the failure of ${row.id}`, countError); + } + } } - processed.push(...bucketRows.map((r) => r.id)); } if (processed.length > 0) { - const { error: updateError } = await supabase - .schema(SCHEMA) - .from("media_pending_deletion") - .update({ processedAt: new Date().toISOString() }) + const { error: updateError } = await queue() + .update({ processedAt: now(), lastError: null }) .in("id", processed); if (updateError) { console.error("cleanup-media: cannot mark rows processed", updateError); @@ -95,6 +134,7 @@ Deno.serve(async (req) => { return Response.json({ processed: processed.length, + rejected: rejected.length, failed: failures, more: rows.length === BATCH_SIZE, }, { status: failures.length > 0 ? 207 : 200 }); diff --git a/supabase/tests/database/chats_test.sql b/supabase/tests/database/chats_test.sql index f88b1a0..6e200a7 100644 --- a/supabase/tests/database/chats_test.sql +++ b/supabase/tests/database/chats_test.sql @@ -6,7 +6,7 @@ BEGIN; CREATE EXTENSION IF NOT EXISTS pgtap WITH SCHEMA extensions; SET search_path = extensions, public; -SELECT plan(67); +SELECT plan(95); -- --------------------------------------------------------------------------- -- Fixtures: four auth users (the chats.users rows come from the trigger). @@ -226,5 +226,135 @@ SELECT is((SELECT count(*)::int FROM pg_inherits i AND has_table_privilege('authenticated', i.inhrelid, 'SELECT')), 0, 'messages partitions are not directly granted to API roles'); +-- =========================================================================== +-- Regression tests of the security audit fixes +-- =========================================================================== + +-- A partition created outside the install scripts is closed right away. +CREATE TABLE chats.messages_ptest PARTITION OF chats.messages + FOR VALUES FROM ('2099-01-01') TO ('2099-01-08'); +SELECT is((SELECT relrowsecurity FROM pg_class WHERE oid = 'chats.messages_ptest'::regclass), true, + 'a partition created by hand gets RLS immediately'); +SELECT is(has_table_privilege('anon', 'chats.messages_ptest', 'SELECT'), false, + 'a partition created by hand is not granted to anon'); +SELECT is((SELECT command FROM cron.job WHERE jobname = 'partman-run-maintenance'), 'CALL partman.run_maintenance_proc()', + 'the maintenance cron job runs the procedure alone (no shared transaction block)'); +SELECT is((SELECT count(*)::int FROM cron.job WHERE jobname = 'chats-messages-partitions-rls'), 1, + 'the partitions RLS fix-up has its own cron job'); + +-- Media cleanup queue: only the deleted message's own room attachments. +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +INSERT INTO chats.messages ("roomId", "authorId", type, source, name, size) +SELECT (SELECT v FROM ids WHERE k = 'direct_ab'), auth.uid(), 'file', s, 'evil', 1 +FROM unnest(ARRAY[ + 'http://127.0.0.1:54321/storage/v1/object/authenticated/chats_assets/' || (SELECT v FROM ids WHERE k = 'group_abc') || '/other-room.pdf', + 'http://127.0.0.1:54321/storage/v1/object/authenticated/chats_user_avatar/fb000000-0000-0000-0000-000000000002/avatar.png', + 'x/object/authenticated/other_bucket/private/report.pdf', + 'http://127.0.0.1:54321/storage/v1/object/authenticated/chats_assets/' +]) AS s; +DELETE FROM chats.messages WHERE name = 'evil'; +SELECT pg_temp.logout(); +SELECT is((SELECT count(*)::int FROM chats.media_pending_deletion + WHERE path LIKE '%other-room.pdf' OR "bucketId" <> 'chats_assets' OR path = ''), 0, + 'a message source cannot queue another room''s, another bucket''s or a malformed object'); + +-- Rooms and memberships: structural columns are not writable. +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +SELECT throws_ok(format($$UPDATE chats.rooms SET type = 'group' WHERE id = %L$$, (SELECT v FROM ids WHERE k = 'direct_ab')), + '42501', NULL, 'a room''s type cannot be changed'); +SELECT throws_ok(format($$UPDATE chats.room_members SET "userId" = 'fc000000-0000-0000-0000-000000000003' WHERE "roomId" = %L AND "userId" = 'fb000000-0000-0000-0000-000000000002'$$, + (SELECT v FROM ids WHERE k = 'direct_ab')), + '42501', NULL, 'a membership cannot be moved to another user'); +SELECT throws_ok(format($$UPDATE chats.room_members SET "roomId" = %L WHERE "roomId" = %L AND "userId" = auth.uid()$$, + (SELECT v FROM ids WHERE k = 'direct_ab'), (SELECT v FROM ids WHERE k = 'group_abc')), + '42501', NULL, 'a membership cannot be moved to another room'); +UPDATE chats.room_members SET role = 'admin' WHERE "roomId" = (SELECT v FROM ids WHERE k = 'direct_ab') AND "userId" = auth.uid(); +SELECT pg_temp.logout(); +SELECT is((SELECT role FROM chats.room_members WHERE "roomId" = (SELECT v FROM ids WHERE k = 'direct_ab') + AND "userId" = 'fa000000-0000-0000-0000-000000000001'), NULL, 'direct room members cannot promote themselves'); +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); +SELECT throws_ok($$UPDATE chats.users SET role = 'admin' WHERE id = auth.uid()$$, '42501', NULL, 'users cannot set their own global role'); + +-- Messages: unique ids, server-owned columns and time. +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +INSERT INTO chats.messages (id, "roomId", "authorId", type, text, pinned, reactions) +VALUES ('f0000000-0000-0000-0000-000000000002', (SELECT v FROM ids WHERE k = 'group_abc'), auth.uid(), 'text', 'original', true, '{"x":["fb000000-0000-0000-0000-000000000002"]}'); +SELECT throws_ok(format($$INSERT INTO chats.messages (id, "roomId", "authorId", type, text, "createdAt") + VALUES ('f0000000-0000-0000-0000-000000000002', %L, auth.uid(), 'text', 'decoy', now() - interval '5 minutes')$$, + (SELECT v FROM ids WHERE k = 'direct_ab')), + '23505', NULL, 'a message id cannot be reused'); +SELECT throws_ok(format($$INSERT INTO chats.messages ("roomId", "authorId", type, text, "createdAt") + VALUES (%L, auth.uid(), 'text', 'future', now() + interval '6 weeks')$$, + (SELECT v FROM ids WHERE k = 'group_abc')), + '22008', NULL, 'a far-future createdAt is rejected'); +UPDATE chats.messages SET pinned = true, status = 'seen', reactions = '{"y":["fc000000-0000-0000-0000-000000000003"]}', + "createdAt" = now() + interval '20 weeks', text = 'edited' +WHERE id = 'f0000000-0000-0000-0000-000000000002'; +SELECT pg_temp.logout(); +SELECT is((SELECT row(pinned, status, reactions, text)::text FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000002'), + row(NULL::boolean, 'sent'::text, NULL::jsonb, 'edited'::text)::text, + 'authors edit the content only: pin, status, reactions are server-owned'); +SELECT ok((SELECT "createdAt" < now() + interval '1 minute' AND "updatedAt" IS NOT NULL FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000002'), + 'createdAt cannot be moved and edits always set updatedAt'); + +-- Forward origin is attested by the database. +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +SELECT throws_ok(format($$INSERT INTO chats.messages ("roomId", "authorId", type, text, metadata) + VALUES (%L, auth.uid(), 'text', 'fake', '{"forwardedFrom":{"authorId":"fb000000-0000-0000-0000-000000000002"}}')$$, + (SELECT v FROM ids WHERE k = 'direct_ab')), + '22023', NULL, 'a forward origin cannot be written by the client'); +INSERT INTO chats.messages ("roomId", "authorId", type, text, metadata) +VALUES ((SELECT v FROM ids WHERE k = 'direct_ab'), auth.uid(), 'text', 'forwarded', + jsonb_build_object('forwardedFrom', jsonb_build_object('sourceRoomId', (SELECT v FROM ids WHERE k = 'group_abc'), + 'sourceMessageId', 'f0000000-0000-0000-0000-000000000002'))); +SELECT is((SELECT metadata -> 'forwardedFrom' ->> 'authorId' FROM chats.messages WHERE text = 'forwarded'), + 'fa000000-0000-0000-0000-000000000001', 'a forward records the real author of its source'); +SELECT pg_temp.login('fd000000-0000-0000-0000-000000000004'); +INSERT INTO ids SELECT 'direct_ad', chats.create_room('direct', ARRAY['fa000000-0000-0000-0000-000000000001']::uuid[]); +SELECT throws_ok(format($$INSERT INTO chats.messages ("roomId", "authorId", type, text, metadata) + VALUES (%L, auth.uid(), 'text', 'stolen', %L)$$, + (SELECT v FROM ids WHERE k = 'direct_ad'), + jsonb_build_object('forwardedFrom', jsonb_build_object('sourceRoomId', (SELECT v FROM ids WHERE k = 'direct_ab'), + 'sourceMessageId', 'f0000000-0000-0000-0000-000000000002'))), + 'P0002', NULL, 'only readable messages can be forwarded'); +SELECT pg_temp.logout(); + +-- Storage: an attachment is overwritten by its uploader, deleted by its +-- uploader or a group admin. Supabase blocks direct deletes from storage +-- tables unless this is set (the Storage API sets it); RLS still applies. +SELECT set_config('storage.allow_delete_query', 'true', true); +INSERT INTO storage.objects (bucket_id, name, owner_id) VALUES + ('chats_assets', (SELECT v FROM ids WHERE k = 'group_abc') || '/carol.png', 'fc000000-0000-0000-0000-000000000003'); +SELECT pg_temp.login('fd000000-0000-0000-0000-000000000004'); +UPDATE storage.objects SET owner_id = auth.uid()::text WHERE name LIKE '%/carol.png'; +DELETE FROM storage.objects WHERE name LIKE '%/carol.png'; +SELECT pg_temp.logout(); +SELECT is((SELECT owner_id FROM storage.objects WHERE name LIKE '%/carol.png'), 'fc000000-0000-0000-0000-000000000003', + 'members cannot overwrite or delete someone else''s attachment'); +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +DELETE FROM storage.objects WHERE name LIKE '%/carol.png'; +SELECT pg_temp.logout(); +SELECT is((SELECT count(*)::int FROM storage.objects WHERE name LIKE '%/carol.png'), 0, 'group admins can delete attachments'); + +-- Realtime: who may send where, and per-user room changes. +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); +SELECT is(chats.is_chat_typing_topic('chats:room:' || (SELECT v FROM ids WHERE k = 'group_abc') || ':typing'), true, + 'members may send on the typing channel'); +SELECT is(chats.is_chat_typing_topic('chats:room:' || (SELECT v FROM ids WHERE k = 'group_abc')), false, + 'nobody may send on the message channel'); +SELECT is(chats.is_chat_member_topic('chats:room:' || (SELECT v FROM ids WHERE k = 'group_abc') || ':typing'), true, + 'members may receive the typing channel'); +SELECT is(chats.is_own_user_topic('chats:user:fb000000-0000-0000-0000-000000000002'), true, 'users receive their own user channel'); +SELECT is(chats.is_own_user_topic('chats:user:fa000000-0000-0000-0000-000000000001'), false, 'users cannot receive someone else''s user channel'); +SELECT is(chats.is_presence_topic('online-user-fa000000-0000-0000-0000-000000000001'), true, 'signed-in users may watch online status'); +SELECT is(chats.is_own_presence_topic('online-user-fa000000-0000-0000-0000-000000000001'), false, 'users cannot publish someone else''s online status'); +SELECT pg_temp.logout(); +SELECT ok(EXISTS (SELECT 1 FROM realtime.messages WHERE topic = 'chats:user:fd000000-0000-0000-0000-000000000004' + AND event = 'room_change' AND payload ->> 'roomId' = (SELECT v FROM ids WHERE k = 'direct_ad')::text), + 'members are told about room changes on their own user channel'); +SELECT is((SELECT count(*)::int FROM pg_publication_tables WHERE pubname = 'supabase_realtime' + AND schemaname = 'chats' AND tablename IN ('rooms', 'room_members')), 0, + 'rooms and memberships are not published to Postgres Changes'); + SELECT * FROM finish(); ROLLBACK; diff --git a/test/integration/chat_integration_test.dart b/test/integration/chat_integration_test.dart index 648efe5..f3179db 100644 --- a/test/integration/chat_integration_test.dart +++ b/test/integration/chat_integration_test.dart @@ -757,5 +757,90 @@ void main() { final profile = await bob.chat.user(uid: alice.id); expect(profile?.lastSeen, isNotNull); }); + + test('members cannot forge message events on the room channel', () async { + final room = await alice.chat.createGroupRoom( + name: 'Forge', + users: [bob.asChatUser], + ); + final controller = SupabaseChatController( + room: room, + chatCore: alice.chat, + autoMarkAsRead: false, + ); + addTearDown(controller.dispose); + await controller.initialLoad; + + // Bob joins the message channel directly, bypassing the library. + final forger = bob.client.channel( + '${bob.chat.config.realtimeRoomChannelPrefix}${room.id}', + opts: const RealtimeChannelConfig(private: true), + )..subscribe(); + addTearDown(() => bob.client.removeChannel(forger)); + await realtimeReady(alice.client); + await realtimeReady(bob.client); + + try { + await forger.sendBroadcastMessage( + event: 'INSERT', + payload: { + 'record': { + 'id': const Uuid().v7(), + 'authorId': alice.id, + 'roomId': room.id, + 'type': 'text', + 'text': 'forged', + 'createdAt': DateTime.now().toUtc().toIso8601String(), + }, + }, + ); + } on Object catch (e) { + // Refused by Realtime: what this test expects. + expect(e, isNotNull); + } + // A genuine message afterwards proves the channel delivers. + await bob.chat.sendMessage(_text(bob, 'genuine'), room.id); + await eventually( + () => controller.messages.any( + (m) => m is core.TextMessage && m.text == 'genuine', + ), + ); + expect( + controller.messages.whereType().map((m) => m.text), + isNot(contains('forged')), + ); + }); + + test('a removed member stops receiving the room', () async { + final eve = await TestUser.create('Eve', 'Removed'); + addTearDown(eve.dispose); + final room = await alice.chat.createGroupRoom( + name: 'Removal', + users: [eve.asChatUser], + ); + final errors = []; + final controller = SupabaseChatController( + room: room, + chatCore: eve.chat, + onError: errors.add, + autoMarkAsRead: false, + ); + addTearDown(controller.dispose); + await controller.initialLoad; + await realtimeReady(eve.client); + + await alice.chat.sendMessage(_text(alice, 'before'), room.id); + await eventually(() => controller.messages.length == 1); + + await alice.chat.removeMember(room.id, eve.id); + await eventually(() => errors.isNotEmpty, reason: 'removal reported'); + + await alice.chat.sendMessage(_text(alice, 'after'), room.id); + await Future.delayed(const Duration(seconds: 2)); + expect( + controller.messages.whereType().map((m) => m.text), + ['before'], + ); + }); }); } diff --git a/test/unit/models_test.dart b/test/unit/models_test.dart index 13906e2..05ae715 100644 --- a/test/unit/models_test.dart +++ b/test/unit/models_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_chat_core/flutter_chat_core.dart' as core; import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' show SupabaseClient; const _me = 'a0000000-0000-0000-0000-000000000001'; const _other = 'b0000000-0000-0000-0000-000000000002'; @@ -268,5 +269,52 @@ void main() { final message = core.Message.text(id: 'm1', authorId: _me, text: 'hi'); expect(ChatForwardOrigin.of(message), isNull); }); + + test('a forward claim only names the source message', () { + final claim = ChatForwardOrigin.claim( + sourceRoomId: 'r1', + sourceMessageId: 'm1', + ); + expect(claim, {'sourceRoomId': 'r1', 'sourceMessageId': 'm1'}); + final message = core.Message.text( + id: 'm2', + authorId: _me, + text: 'hi', + metadata: {ChatForwardOrigin.metadataKey: claim}, + ); + expect(ChatForwardOrigin.of(message), isNull); + }); + }); + + group('SupabaseChatCore.httpSupabaseHeadersFor', () { + final chat = SupabaseChatCore( + client: SupabaseClient('http://127.0.0.1:54321', 'anon-key'), + ); + tearDownAll(chat.dispose); + + test('recognizes only this project storage object URLs', () { + expect(chat.isStorageObjectUrl(chat.getAssetUrl('r1/a.png')), isTrue); + expect( + chat.isStorageObjectUrl( + chat.getAssetUrl('u1/a.png', bucket: 'chats_user_avatar'), + ), + isTrue, + ); + for (final url in [ + 'https://attacker.example/avatar.png', + 'https://attacker.example/storage/v1/object/authenticated/x.png', + 'http://127.0.0.1:9999/storage/v1/object/authenticated/x.png', + 'http://127.0.0.1:54321/rest/v1/messages', + 'http://127.0.0.1:54321/storage/v1/object/authenticated/../x.png', + 'not a url', + '', + ]) { + expect(chat.isStorageObjectUrl(url), isFalse, reason: url); + } + }); + + test('sends no credentials when signed out', () { + expect(chat.httpSupabaseHeadersFor(chat.getAssetUrl('r1/a.png')), {}); + }); }); }